SEC-001
Fetch a user-supplied URL without opening an SSRF hole
Postback URLs, webhook targets and site verification all mean fetching an address someone else chose. This is the guarded version.
The moment your site fetches a URL a user typed in, that user can point it at your own network: localhost, the metadata endpoint on a cloud box, a database admin panel on a private address. From the outside it looks like your server making the request, because it is.
The guard has three parts: allow only http and https, resolve the host and reject private or reserved addresses, and refuse to follow redirects, because a public URL that 302s to 127.0.0.1 defeats a check done only on the original address.
function fetch_guarded(string $url, int $timeout = 8): ?string
{
$parts = parse_url($url);
if (!$parts || !in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true)) {
return null;
}
$host = $parts['host'] ?? '';
if ($host === '') {
return null;
}
// Resolve first, and reject anything that is not a public address.
$ips = array_merge(
gethostbynamel($host) ?: [],
array_column(@dns_get_record($host, DNS_AAAA) ?: [], 'ipv6')
);
if (!$ips) {
return null;
}
foreach ($ips as $ip) {
$public = filter_var(
$ip,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
);
if ($public === false) {
return null; // private, loopback, link-local, reserved
}
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false, // a redirect can point back inside
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_MAXFILESIZE => 512 * 1024,
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
]);
$body = curl_exec($ch);
curl_close($ch);
return $body === false ? null : (string) $body;
}
Using it
Use this for every outbound request whose address came from a user: offerwall postbacks, site verification fetches, avatar imports.
Cap the response size. A user-supplied URL that streams gigabytes is a denial of service with no exploit required.
What bites people
There is a race between resolving the host and cURL resolving it again. For most sites this is acceptable; if you need it airtight, resolve once and connect to the address directly with the Host header set.
Turning off certificate verification to make a stubborn callback work reopens everything this function closes. Fix the certificate instead.
This one touches real money. Point it at a throwaway wallet and watch a full cycle before you trust it with a live balance.