Skip to content
Merged
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
59 changes: 1 addition & 58 deletions src/Input/UrlInputSource.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,7 @@ public function __construct(string $url)
/**
* Validates that a URL is safe to fetch.
*
* Rejects URLs with:
* - non-HTTPS schemes,
* - embedded user credentials,
* - loopback hostnames (localhost, *.localhost),
* - literal loopback, link-local, or private-network IP addresses.
*
* Note: DNS resolution is not performed — a hostname that resolves to a
* private IP will not be caught here.
*
* Rejects URLs with non-HTTPS schemes.
* @param string $url URL to validate.
* @throws MindeeSourceException Throws if the URL is invalid or unsafe.
*/
Expand All @@ -52,55 +44,6 @@ private function validateUrl(string $url): void
if (!isset($parsed['scheme']) || strtolower($parsed['scheme']) !== 'https') {
throw new MindeeSourceException('URL must be HTTPS', ErrorCode::USER_INPUT_ERROR);
}

if (!empty($parsed['user']) || !empty($parsed['pass'])) {
throw new MindeeSourceException(
'URL must not embed user credentials',
ErrorCode::USER_INPUT_ERROR
);
}

$host = strtolower($parsed['host'] ?? '');
if ($host === '') {
throw new MindeeSourceException('URL is missing a host', ErrorCode::USER_INPUT_ERROR);
}

// Strip IPv6 brackets
$host = preg_replace('/^\[|]$/', '', $host);

if (
$host === 'localhost'
|| str_ends_with((string) $host, '.localhost')
|| $host === 'ip6-localhost'
|| $host === 'ip6-loopback'
) {
throw new MindeeSourceException(
'URL host is a loopback address',
ErrorCode::USER_INPUT_ERROR
);
}

if (
$host === '::1'
|| preg_match('/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/', (string) $host)
) {
throw new MindeeSourceException(
'URL host is a loopback or private address',
ErrorCode::USER_INPUT_ERROR
);
}

if (
preg_match('/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/', (string) $host)
|| preg_match('/^172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$/', (string) $host)
|| preg_match('/^192\.168\.\d{1,3}\.\d{1,3}$/', (string) $host)
|| preg_match('/^169\.254\.\d{1,3}\.\d{1,3}$/', (string) $host)
) {
throw new MindeeSourceException(
'URL host is a loopback or private address',
ErrorCode::USER_INPUT_ERROR
);
}
}

/**
Expand Down
65 changes: 1 addition & 64 deletions tests/Input/UrlInputSourceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ protected function tearDown(): void
putenv('MINDEE_API_KEY=' . $this->oldKey);
}

public function testInputFromHttpShouldNotThrow(): void
public function testInputFromHttpsShouldNotThrow(): void
{
$inputDoc = new UrlInputSource("https://example.com/invoice.pdf");
self::assertInstanceOf(UrlInputSource::class, $inputDoc);
Expand All @@ -39,69 +39,6 @@ public function testInputFromHttpShouldThrow(): void
new UrlInputSource(url: "http://example.com/invoice.pdf");
}

public function testRejectsEmbeddedCredentials(): void
{
$this->expectException(MindeeSourceException::class);
$this->expectExceptionMessage('URL must not embed user credentials');
new UrlInputSource('https://user:pass@example.com/invoice.pdf');
}

public function testRejectsLocalhostHostname(): void
{
$this->expectException(MindeeSourceException::class);
$this->expectExceptionMessage('URL host is a loopback address');
new UrlInputSource('https://localhost/invoice.pdf');
}

public function testRejectsDotLocalhostHostname(): void
{
$this->expectException(MindeeSourceException::class);
$this->expectExceptionMessage('URL host is a loopback address');
new UrlInputSource('https://foo.localhost/invoice.pdf');
}

public function testRejectsLoopbackIpv4(): void
{
$this->expectException(MindeeSourceException::class);
$this->expectExceptionMessage('URL host is a loopback or private address');
new UrlInputSource('https://127.0.0.1/invoice.pdf');
}

public function testRejectsLoopbackIpv6(): void
{
$this->expectException(MindeeSourceException::class);
$this->expectExceptionMessage('URL host is a loopback or private address');
new UrlInputSource('https://[::1]/invoice.pdf');
}

public function testRejectsPrivateRfc1918Class10(): void
{
$this->expectException(MindeeSourceException::class);
$this->expectExceptionMessage('URL host is a loopback or private address');
new UrlInputSource('https://10.0.0.1/invoice.pdf');
}

public function testRejectsPrivateRfc1918Class172(): void
{
$this->expectException(MindeeSourceException::class);
$this->expectExceptionMessage('URL host is a loopback or private address');
new UrlInputSource('https://172.16.0.1/invoice.pdf');
}

public function testRejectsPrivateRfc1918Class192(): void
{
$this->expectException(MindeeSourceException::class);
$this->expectExceptionMessage('URL host is a loopback or private address');
new UrlInputSource('https://192.168.1.1/invoice.pdf');
}

public function testRejectsLinkLocalAddress(): void
{
$this->expectException(MindeeSourceException::class);
$this->expectExceptionMessage('URL host is a loopback or private address');
new UrlInputSource('https://169.254.0.1/invoice.pdf');
}

public function testDownloadFileFails(): void
{
$dummyAddress = "addressthatdoesntworkforcipurposes";
Expand Down
Loading