Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/Extractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,9 @@ public function resolveUri($uri): UriInterface
if (!isHttp($uri)) {
throw new InvalidArgumentException(sprintf('Uri string must use http or https scheme (%s)', $uri));
}

if (!isValidUrl($uri)) {
throw new InvalidArgumentException(sprintf('Access to this URL is blocked for security reasons (%s)', $uri));
}
$uri = $this->crawler->createUri($uri);
}

Expand Down
78 changes: 75 additions & 3 deletions src/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,86 @@ function resolveUri(UriInterface $base, UriInterface $uri): UriInterface
->withFragment('');
}

/**
* Check if the DNS associated with the URL is valid (SSRF).
*/
function isValidUrl(string $url): bool
{
// First, use standard PHP URL filtering.
if (!filter_var($url, FILTER_VALIDATE_URL)) {
return false;
}
$parts = parse_url($url);
$host = $parts['host'] ?? '';
// Normalize IPv6 literal formatting wrapping (e.g., [::1] -> ::1)
if (strpos($host, '[') === 0 && strpos($host, ']') === (strlen($host) - 1)) {
$host = substr($host, 1, -1);
}
// Collect all IPs the host resolves to.
$ips = [];
if (filter_var($host, FILTER_VALIDATE_IP)) {
// The host is already a direct IP address literal.
$ips[] = $host;
}
else {
// Resolve DNS records for both IPv4 (A) and IPv6 (AAAA).
$dnsA = @dns_get_record($host, DNS_A);
$dnsAAAA = @dns_get_record($host, DNS_AAAA);
if (is_array($dnsA)) {
foreach ($dnsA as $record) {
if (isset($record['ip'])) {
$ips[] = $record['ip'];
}
}
}
if (is_array($dnsAAAA)) {
foreach ($dnsAAAA as $record) {
if (isset($record['ipv6'])) {
$ips[] = $record['ipv6'];
}
}
}
// Fallback. If dns_get_record fails but gethostbyname finds something.
if (empty($ips)) {
$fallbackIp = @gethostbyname($host);
if ($fallbackIp !== $host) {
$ips[] = $fallbackIp;
}
else {
// If DNS resolution fails, treat URL as invalid.
return false;
}
}
}
// 4. Validate resolved IPs against standard restricted ranges
foreach ($ips as $ip) {
// Check if the IP is valid and falls outside reserved/private scopes
// FILTER_FLAG_NO_PRIV_RANGE: Blocks RFC1918 (10/8, 172.16/12, 192.168/16)
// FILTER_FLAG_NO_RES_RANGE: Blocks Loopback (127.0.0.0/8, ::1)
// and Link-Local (169.254.0.0/16).
$isPublic = filter_var(
$ip,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
);
if (!$isPublic) {
// The IP belongs to a restricted/private range.
return false;
}
}
return true;
}

function isHttp(string $uri): bool
{
$result = preg_match('/^(\w+):/', $uri, $matches);
if ($result !== false && $result > 0) {
return in_array(strtolower($matches[1]), ['http', 'https'], true);
if ($result === 1) {
$scheme = strtolower($matches[1]);
return in_array($scheme, ['http', 'https'], true);
}

return true;
// SECURE: Reject URIs without explicit http/https scheme
return false;
}

function resolvePath(string $base, string $path): string
Expand Down
45 changes: 40 additions & 5 deletions tests/FunctionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
namespace Embed\Tests;

use function Embed\isHttp;
use function Embed\isValidUrl;
use PHPUnit\Framework\TestCase;


class FunctionsTest extends TestCase
{
public function urlsProvider(): array
Expand All @@ -16,11 +18,35 @@ public function urlsProvider(): array
['mailto:foo@example.com', false],
['tel:+1234567890', false],
['data:foo', false],
['./foo', true],
['/foo', true],
['../foo', true],
['foo.com', true],
['//foo.com', true],
['./foo', false],
['/foo', false],
['../foo', false],
['foo.com', false],
['//foo.com', false],
['//internal.local/admin', false],
];
}

public function invalidUrlsProvider(): array {
return [
['https://169.254.0.0/SSRF_PATH', false],
['https://169.254.169.254/latest/meta-data/iam/security-credentials/', false],
['https://127.0.0.1:9999/SSRF_PATH', false],
['http://127.0.0.1:9999/SSRF_PATH', false],
['https://10.0.0.0/SSRF_PATH', false],
['https://172.16.0.0/SSRF_PATH', false],
['https://192.168.0.0/SSRF_PATH', false],
['http://localhost:8080/admin', false],
['169.254.0.0/SSRF_PATH', false],
['10.0.0.0/SSRF_PATH', false],
['172.16.0.0/SSRF_PATH', false],
['192.168.0.0/SSRF_PATH', false],
['./foo', false],
['/foo', false],
['../foo', false],
['foo.com', false],
['https://foo.com', true],
['https://example.com', true],
];
}

Expand All @@ -32,4 +58,13 @@ public function testIsHttp(string $url, bool $expected)
$result = isHttp($url);
$this->assertSame($expected, $result);
}

/**
* @dataProvider invalidUrlsProvider
*/
public function testIsValidUrl(string $url, bool $expected)
{
$result = isValidUrl($url);
$this->assertSame($expected, $result);
}
}