Security 11 min read Markdown

SSRF in Laravel: The Risk Hiding in Http::get()

A single user-supplied URL passed into Laravel's HTTP client can let an attacker read your cloud metadata and steal IAM credentials. Here is how SSRF works and how to build a URL validator that actually blocks it.

Matt King
Matt King
June 25, 2026
Last updated: June 25, 2026
SSRF in Laravel: The Risk Hiding in Http::get()

Your Laravel API has authentication. Tokens are required. HTTPS is enforced. You might think you are covered, but authentication only controls who can talk to your server. It says nothing about who your server talks to on the user's behalf. The moment your application fetches a URL that a user supplied, the attacker borrows your server's network position, and that position is far more privileged than they could ever reach from the outside.

That class of bug is server-side request forgery, or SSRF. It rarely looks dangerous in code review because the offending line is usually one call to the HTTP client. The danger is not in the syntax. It is in where that request can travel.


How SSRF shows up in real features

SSRF is not an exotic vulnerability you have to go looking for. It rides along with features product teams ship every week. Anywhere your backend takes a URL and fetches it, you have a candidate.

  • Webhook URLs. A user registers a callback so you can POST events to their endpoint. You fetch or ping that URL to verify it works.
  • Link and URL previews. Paste a link into a comment box and the app fetches the page to render a title, description and thumbnail.
  • Import from URL. "Import your data" or "import from a Google Sheet" features that download a file from a user-provided address.
  • Avatar fetch by URL. Instead of uploading, the user pastes an image URL and your server downloads it. This overlaps with the upload risks covered in Laravel file upload security.
  • PDF and screenshot services. Render a page to PDF or capture a screenshot of a URL the user names.
  • Server-side image proxies. Resize or cache remote images by fetching them through your backend.

Every one of these has a legitimate reason to make an outbound request to a URL chosen by a user. That is exactly what makes them dangerous. The feature works as designed and the attack works as designed at the same time.


The pattern that gets shipped

Here is the version that lands in a pull request, gets approved, and runs in production for a year before anyone notices.

// app/Http/Controllers/LinkPreviewController.php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;

public function preview(Request $request)
{
    $request->validate([
        'url' => ['required', 'url'],
    ]);

    // Fetch whatever the user pointed us at.
    $response = Http::get($request->input('url'));

    return response()->json([
        'status' => $response->status(),
        'body'   => $response->body(),
    ]);
}

The url validation rule feels like protection. It is not. It checks that the string is a syntactically valid URL. http://169.254.169.254/latest/meta-data/ is a syntactically valid URL. So is http://localhost:6379/. So is file:///etc/passwd in some client configurations. The rule has no opinion about where the URL points, and where it points is the entire problem.


What the attacker actually reaches

Your application server sits inside a network. From the public internet, that network is mostly closed. From inside it, all sorts of things answer.

Internal services. A Redis instance on http://localhost:6379, an Elasticsearch node on http://10.0.1.20:9200, an internal admin panel on http://admin.internal:8080 that trusts requests by IP. None of these expect a hostile request, because none of them are reachable from outside. SSRF makes them reachable.

The cloud metadata endpoint. This is the prize. On AWS, every EC2 instance can query http://169.254.169.254/latest/meta-data/. Walk the path to iam/security-credentials/<role-name> and the response contains temporary access keys for whatever IAM role the instance runs as.

GET http://169.254.169.254/latest/meta-data/iam/security-credentials/app-role

{
  "AccessKeyId": "ASIA...",
  "SecretAccessKey": "...",
  "Token": "...",
  "Expiration": "2026-06-25T18:00:00Z"
}

Those credentials carry whatever permissions the role has. If the role can read an S3 bucket, so can the attacker now. If it can describe your infrastructure or assume other roles, the blast radius grows fast. An SSRF that returns the response body to the user hands these keys over directly. Even a blind SSRF, where the response is not returned, can sometimes be exfiltrated through timing or side channels.

Why IMDSv2 changes the maths

The original instance metadata service (IMDSv1) answers a plain GET. That is what makes it such a clean SSRF target: a single forged GET request is all it takes.

IMDSv2 requires a two-step exchange. You first send a PUT to /latest/api/token with a header requesting a session token, then include that token on subsequent metadata requests. A typical SSRF primitive only lets the attacker control a URL for a GET-style fetch. It cannot send the PUT with the right header, so IMDSv2 blocks the common case outright. AWS lets you enforce IMDSv2 and set the response hop limit to 1, which stops the token from being proxied. Enable it. It is one of the highest-value, lowest-effort hardening steps for any EC2-hosted Laravel app, and it complements the network hygiene discussed in open ports and production security.

IMDSv2 is a mitigation, not a cure. It does nothing for the internal-service targets, and other clouds differ. You still need to validate at the application layer.


Building a defence that holds

The fix is not a single check. SSRF defence is layered because attackers have several ways around any one control. Walk through them in order.

1. Reject schemes that are not http or https

The HTTP client can sometimes be coaxed into handling file://, gopher://, ftp:// and similar. None of those belong in a "fetch a web page" feature. Allow exactly two schemes and reject the rest before doing anything else.

2. Resolve the host and block private and reserved ranges

A public-looking hostname can resolve to a private address. Resolve it yourself and check the resulting IP against the ranges that should never be the target of a user-driven fetch.

Risky target / scheme What it reaches Control that blocks it
file:///etc/passwd Local filesystem Scheme allowlist (http/https only)
gopher://, ftp://, dict:// Protocol smuggling to internal services Scheme allowlist
http://127.0.0.1, http://localhost Loopback services on the box Block 127.0.0.0/8 and ::1
http://10.0.x.x, 172.16-31.x.x, 192.168.x.x Internal RFC1918 network Block private IPv4 ranges
http://169.254.169.254 Cloud metadata / IAM credentials Block 169.254.0.0/16 link-local
http://[::ffff:127.0.0.1], http://[fd00::1] IPv6 loopback / unique-local Block IPv6 loopback, link-local, ULA
Allowed host returns 302 to internal IP Redirect bypass Disable or re-validate redirects
Allowed host re-resolves to internal IP mid-request DNS rebinding Pin the validated IP for the request

3. A reusable SafeUrlValidator

Here is a validator that does the scheme check and the IP-range check in one place. It rejects the dangerous ranges for both IPv4 and IPv6, and it returns the resolved IP so the caller can pin it.

// app/Support/SafeUrlValidator.php

namespace App\Support;

use RuntimeException;

class SafeUrlValidator
{
    /**
     * Optional allowlist of permitted hostnames. When non-empty,
     * the URL host must match one of these exactly.
     *
     * @param  array<int, string>  $allowedHosts
     */
    public function __construct(private array $allowedHosts = [])
    {
    }

    /**
     * Validate the URL and return the resolved IP to pin the request to.
     */
    public function validate(string $url): ResolvedTarget
    {
        $parts = parse_url($url);

        if ($parts === false || empty($parts['host']) || empty($parts['scheme'])) {
            throw new RuntimeException('Malformed URL.');
        }

        $scheme = strtolower($parts['scheme']);
        if (! in_array($scheme, ['http', 'https'], true)) {
            throw new RuntimeException("Scheme not allowed: {$scheme}");
        }

        $host = $parts['host'];

        if ($this->allowedHosts !== [] && ! in_array($host, $this->allowedHosts, true)) {
            throw new RuntimeException("Host not on allowlist: {$host}");
        }

        $ips = $this->resolve($host);

        foreach ($ips as $ip) {
            if ($this->isBlockedIp($ip)) {
                throw new RuntimeException("Resolved to a blocked address: {$ip}");
            }
        }

        // Pin the first validated IP so the actual request cannot be rebound.
        return new ResolvedTarget($url, $host, $ips[0]);
    }

    /**
     * @return array<int, string>
     */
    private function resolve(string $host): array
    {
        // If the host is already a literal IP, validate it directly.
        if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
            return [$host];
        }

        $records = @dns_get_record($host, DNS_A | DNS_AAAA);

        if ($records === false || $records === []) {
            throw new RuntimeException("Could not resolve host: {$host}");
        }

        $ips = [];
        foreach ($records as $record) {
            $ips[] = $record['ip'] ?? $record['ipv6'] ?? null;
        }

        $ips = array_values(array_filter($ips));

        if ($ips === []) {
            throw new RuntimeException("No usable address for host: {$host}");
        }

        return $ips;
    }

    private function isBlockedIp(string $ip): bool
    {
        // PHP\'s filter flags reject private and reserved ranges, covering
        // 10/8, 172.16/12, 192.168/16, 127/8, 169.254/16 and IPv6 equivalents.
        $isPublic = filter_var(
            $ip,
            FILTER_VALIDATE_IP,
            FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE,
        );

        return $isPublic === false;
    }
}
// app/Support/ResolvedTarget.php

namespace App\Support;

class ResolvedTarget
{
    public function __construct(
        public readonly string $url,
        public readonly string $host,
        public readonly string $ip,
    ) {
    }
}

A note on FILTER_FLAG_NO_RES_RANGE: it covers reserved blocks including 169.254.0.0/16 link-local, which is the metadata address you most care about. Combined with FILTER_FLAG_NO_PRIV_RANGE for RFC1918, the single filter_var call rejects the targets in the table above for both IP families. If you want to be explicit rather than relying on the flags, keep your own CIDR list and test each IP against it; the result should match.

4. The fixed controller

Now the controller validates first, and pins the connection so a rebind cannot move the target after the check.

// app/Http/Controllers/LinkPreviewController.php

use App\Support\SafeUrlValidator;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;

public function preview(Request $request, SafeUrlValidator $validator)
{
    $request->validate(['url' => ['required', 'url']]);

    try {
        $target = $validator->validate($request->input('url'));
    } catch (\RuntimeException $e) {
        return response()->json(['error' => $e->getMessage()], 422);
    }

    $response = Http::withOptions([
            // Do not follow redirects automatically. An allowed host
            // can 302 to an internal address; we will not chase it.
            'allow_redirects' => false,

            // Pin the connection to the IP we validated. Guzzle resolves
            // the host to this exact address, defeating DNS rebinding.
            'curl' => [
                CURLOPT_RESOLVE => [
                    "{$target->host}:80:{$target->ip}",
                    "{$target->host}:443:{$target->ip}",
                ],
            ],
        ])
        ->timeout(5)
        ->get($target->url);

    return response()->json([
        'status' => $response->status(),
        'body'   => $response->body(),
    ]);
}

Two settings carry the weight here. allow_redirects => false stops an allowed host from bouncing you to http://169.254.169.254 with a 302. And CURLOPT_RESOLVE forces Guzzle's underlying cURL handle to connect to the IP you already validated, instead of resolving the hostname a second time. That second resolution is the window a DNS rebinding attack lives in.


Redirects and DNS rebinding, the two checks people forget

The scheme check and IP-range check feel complete. They are not, because both can be bypassed by an attacker who controls a host that passes your checks.

The redirect bypass. You allow images.partner.com. It resolves to a public IP, so it passes. Then it responds with 302 Location: http://169.254.169.254/latest/meta-data/. If your client follows redirects, it follows that one, straight into the metadata service, with none of your validation applied to the new destination. Either disable redirect following entirely, or run every redirect target back through SafeUrlValidator before following it.

DNS rebinding. You validate attacker.com. At validation time its DNS record points at a harmless public IP, so it passes. The attacker has set a very short TTL. By the time your HTTP client opens the connection a few milliseconds later, the record now points at 169.254.169.254. Your validation looked at one IP; your request reached another. Pinning the connection with CURLOPT_RESOLVE to the exact IP you validated closes this gap because the client never performs that second lookup.

If you cannot pin the IP for some reason, the fallback is to resolve once, validate, and connect by IP while passing the original hostname in the Host header. The principle is the same: validate and connect must agree on a single IP.


Where this fits in the bigger picture

SSRF is one of the access-control failures that the OWASP Top 10 for Laravel tracks, and it tends to chain with other weaknesses. An SSRF that reaches an internal service is far more damaging if that service is also exposed on an open port, which is why network and application controls reinforce each other. The same goes for trust placed in external hostnames; a dangling DNS record can turn a once-safe allowlisted host into an attacker-controlled one, the same root cause behind subdomain takeover.

A quick audit you can run today: grep your codebase for Http::get, Http::post, Http::withOptions, and raw Guzzle client usage, then trace each one back to its source. Any path where $request->input(...), route parameters or stored user data reach the URL argument without passing through validation is a finding. Treat stored URLs with the same suspicion as live request input; a webhook saved last month is still attacker-controlled.

Control Stops Effort
Scheme allowlist file://, gopher://, protocol smuggling Low
Private/reserved IP block Internal services, metadata endpoint Low
Host allowlist Arbitrary destinations Low
Disable/re-validate redirects 302 bypass to internal IP Medium
Pin resolved IP DNS rebinding Medium
Enforce IMDSv2 (AWS) Metadata GET abuse Low

The one rule that prevents most of this

Never pass a user-supplied URL straight into the HTTP client. Resolve the hostname, validate the IP it resolves to, and make the request against that exact IP. Everything above is detail in service of that single discipline. A URL string is data, and like all user data it is hostile until you have proven otherwise.

Want to know which of your controllers fetch user-supplied URLs without that check? Run a free StackShield scan and find the lines before someone else does.

Free security check

Is your Laravel app exposed right now?

34% of Laravel apps we scan have at least one critical issue. Most teams don't find out until something breaks. Our free scan checks your live application in under 60 seconds.

18% have debug mode on
72% missing security headers
12% have exposed .env
Scan My App Free No signup required. Results in 60 seconds.

Frequently Asked Questions

What is SSRF in a Laravel application?

Server-side request forgery happens when your application makes an outbound HTTP request to a URL that an attacker controls or influences. The attacker does not reach the target directly; your server does it for them, using your server's network position and credentials. Because the request originates inside your infrastructure, it can reach internal services and cloud metadata endpoints that the public internet cannot. In Laravel this usually shows up wherever a feature accepts a URL and fetches it, such as webhooks, link previews or avatar imports.

Why is the cloud metadata endpoint such a common SSRF target?

On AWS, the address 169.254.169.254 serves instance metadata, including temporary IAM credentials tied to the instance role. If an attacker can make your server request that address, the response can hand them access keys that work against your AWS account. The same idea applies to GCP and Azure with their own metadata addresses. IMDSv2 mitigates this by requiring a PUT request to obtain a session token first, which a simple GET-based SSRF cannot perform, so enabling it raises the bar considerably.

Is an allowlist of hostnames enough to stop SSRF?

A hostname allowlist is a good first layer but it is not sufficient on its own. An allowed host can issue a 302 redirect to an internal IP address, and DNS for an allowed name can be rebound to a private address between your validation check and the actual request. You also need to resolve the hostname, reject private and reserved IP ranges, control redirect following and pin the resolved IP for the request you actually send. Treat the allowlist as one control among several, not the whole defence.

What is DNS rebinding and how does it defeat URL validation?

DNS rebinding exploits the gap between when you validate a URL and when you fetch it. You resolve attacker.com, see a public IP, decide it is safe, then make the request. By the time the HTTP client resolves the name a second time, the attacker has changed the DNS record to point at 169.254.169.254 or 127.0.0.1. The fix is to resolve the hostname once, validate that specific IP, then force the HTTP request to connect to the exact IP you validated rather than re-resolving the name.

Does StackShield detect SSRF patterns in Laravel code?

StackShield scans for user-controlled input flowing into HTTP client calls such as Http::get, Http::post and Guzzle requests without intervening validation. It flags places where request data reaches an outbound request and highlights missing allowlist or IP-range checks. The scan also looks for related exposure like open internal ports and misconfigured services that an SSRF chain could reach. You can run a free scan against your repository to see where user-supplied URLs are handled.

Stay Updated on Laravel Security

Get actionable security tips, vulnerability alerts, and best practices for Laravel apps.