Skip to main content
Laravel, shipping fast.
Chapter 19 · A Security Review

7. Server-Side Request Forgery

Julian Beaujardin

Your server makes an HTTP request to a URL that an attacker chose.

Here: this API has exactly one place where a caller supplies a URL: webhook_url, from Chapter 18. It is easy to miss, and it is the most dangerous field in the system, for the reasons that chapter gave.

What stops it: one class, used at both ends. As a validation rule when the URL is saved:

// app/Rules/PublicUrl.php
public function validate(
    string $attribute,
    mixed $value,
    Closure $fail,
): void {
    try {
        self::resolve(is_string($value) ? $value : '');
    } catch (UnsafeWebhookUrl) {
        $fail('validation.public_url')->translate();
    }
}

And through resolve() itself, just before each delivery:

public static function resolve(string $url): string
{
    $host = (string) parse_url($url, PHP_URL_HOST);

    $records = $host === ''
        ? []
        : dns_get_record($host, DNS_A | DNS_AAAA);

    if ($records === false || $records === []) {
        throw new UnsafeWebhookUrl;
    }

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

        if (! self::isPublic($ip)) {
            throw new UnsafeWebhookUrl;
        }
    }

    return $url;
}
private const REFUSED = [
    '64:ff9b::/96', '64:ff9b:1::/48', // NAT64
    '::ffff:0:0:0/96', '::/96', // IPv4 inside IPv6
    '3fff::/20', '5f00::/16', 'fec0::/10',
    'ff00::/8', '224.0.0.0/4', // multicast
];

private static function isPublic(string $ip): bool
{
    $global = filter_var(
        $ip,
        FILTER_VALIDATE_IP,
        FILTER_FLAG_GLOBAL_RANGE,
    ) !== false;

    return $global && ! IpUtils::checkIp($ip, self::REFUSED);
}

It looks up every address the host resolves to, IPv4 and IPv6, and refuses the URL if any one of them is not a public address. Checking only the first address is the classic mistake: a host can answer with one public address and one internal one.

FILTER_FLAG_GLOBAL_RANGE rejects private networks, loopback, link-local addresses including the cloud metadata address, and most other reserved ranges. It lets a few through, and the constant names them: the NAT64, IPv4-translated, and IPv4-compatible prefixes, which can reach private IPv4 addresses from an IPv6 network, two ranges reserved for documentation and routing experiments, an old site-local range, and multicast. IpUtils is the class Laravel’s own trusted-proxy check uses.

With url:https in the rule and withoutRedirecting() on the client, that closes the common attacks. It does not close all of them, and a review should say which. Between this check and the connection, the HTTP client resolves the name again, and an attacker who controls the DNS server can answer differently the second time. The client’s resolver also reads the server’s own hosts file, which dns_get_record() never sees.

The complete fix is to connect to the address you checked. cURL can pin a host to an address for one request:

->withOptions(['curl' => [
    CURLOPT_RESOLVE => ["{$host}:{$port}:{$ip}"],
]])

Have resolve() return the address it approved, pass it here, and the name is never looked up a second time. $port is the port in the URL, or 443 when there is none: a pin for the wrong port pins nothing. An IPv6 address goes in square brackets. The other complete fix is to send all webhook traffic through a proxy that refuses internal destinations. If webhooks matter to your product, do one of those.

How you know: search the codebase for every outgoing HTTP call and, for each, ask where the URL comes from. A URL from config is fine. A URL from a database column that a request can write is this vulnerability until proven otherwise.

The audio could not be loaded. Try again in a moment.