diff --git a/composer.json b/composer.json index 2c38a14b..615b69a5 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,8 @@ "symfony/console": ">=6.0", "setasign/fpdf": "^1.8", "setasign/fpdi": "^2.6.4", - "smalot/pdfparser": "^2.12" + "smalot/pdfparser": "^2.12", + "composer/ca-bundle": "^1.5" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.38", diff --git a/src/CustomSleepMixin.php b/src/CustomSleepMixin.php index bb4203c8..8ef468a2 100644 --- a/src/CustomSleepMixin.php +++ b/src/CustomSleepMixin.php @@ -4,26 +4,37 @@ namespace Mindee; +use Mindee\Error\MindeeException; +use Mindee\Http\CancellationToken; + trait CustomSleepMixin { /** * Waits for a custom amount of time from either a float or an integer. - * Purposefully waits for one more millisecond on windows due to flakiness in delays between OS. + * Purposefully waits for one more millisecond on Windows due to flakiness in delays between OS. * @param float|integer $delay Delay in seconds. + * @param CancellationToken|null $cancellationToken CancellationToken to check for cancellation. */ - protected static function customSleep(float|int $delay): void + protected static function customSleep(float|int $delay, ?CancellationToken $cancellationToken = null): void { if ($delay <= 0) { return; } - - $seconds = (int) $delay; - $nanoseconds = abs($seconds - (float) $delay); - if ( - strtoupper(substr(PHP_OS_FAMILY, 0, 7)) === 'WINDOWS' - ) { + $endTime = microtime(true) + $delay; + $pollIntervalMicroseconds = 100_000; + while (microtime(true) < $endTime) { + if ($cancellationToken && $cancellationToken->isCancelled()) { + throw new MindeeException("Polling operation was cancelled."); + } + $remainingSeconds = $endTime - microtime(true); + if ($remainingSeconds <= 0) { + break; + } + $sleepMicroseconds = (int) min($pollIntervalMicroseconds, $remainingSeconds * 1_000_000); + usleep($sleepMicroseconds); + } + if (PHP_OS_FAMILY === 'Windows') { usleep(1000); } - time_nanosleep($seconds, (int) ($nanoseconds * 1_000_000_000)); } } diff --git a/src/Http/CancellationToken.php b/src/Http/CancellationToken.php new file mode 100644 index 00000000..cb41ff3c --- /dev/null +++ b/src/Http/CancellationToken.php @@ -0,0 +1,39 @@ +isCanceled = true; + } + + /** + * Checks whether the token is canceled. + * @return boolean whether the token is canceled. + */ + public function isCanceled(): bool + { + return $this->isCanceled; + } + + /** + * Checks whether the token is canceled, but in British. + * @return boolean whether the token is canceled. + */ + public function isCancelled(): bool + { + return $this->isCanceled; + } +} diff --git a/src/Http/CurlSslConfig.php b/src/Http/CurlSslConfig.php new file mode 100644 index 00000000..64d17f36 --- /dev/null +++ b/src/Http/CurlSslConfig.php @@ -0,0 +1,36 @@ +validateUrl($url); + $this->url = $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. + * + * @param string $url URL to validate. + * @throws MindeeSourceException Throws if the URL is invalid or unsafe. + */ + private function validateUrl(string $url): void + { + $parsed = parse_url($url); + if ($parsed === false) { + throw new MindeeSourceException('Invalid URL', ErrorCode::USER_INPUT_ERROR); + } + + 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 be HTTPS', + '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 ); } - $this->url = $url; } /** diff --git a/src/V1/Client.php b/src/V1/Client.php index a5fdc433..b2e81df2 100644 --- a/src/V1/Client.php +++ b/src/V1/Client.php @@ -16,6 +16,7 @@ use Mindee\Error\ErrorCode; use Mindee\Error\MindeeApiException; use Mindee\Error\MindeeException; +use Mindee\Http\CancellationToken; use Mindee\Input\InputSource; use Mindee\Input\LocalInputSource; use Mindee\Input\LocalResponse; @@ -349,6 +350,7 @@ public function parse( * @param PredictMethodOptions|null $options Prediction Options. * @param PollingOptions|null $asyncOptions Async Options. Manages timers. * @param PageOptions|null $pageOptions Options to apply to the PDF file. + * @param CancellationToken|null $cancellationToken CancellationToken to check for cancellation. * @throws MindeeApiException Throws if the document couldn't be retrieved in time. */ public function enqueueAndParse( @@ -356,7 +358,8 @@ public function enqueueAndParse( InputSource $inputDoc, ?PredictMethodOptions $options = null, ?PollingOptions $asyncOptions = null, - ?PageOptions $pageOptions = null + ?PageOptions $pageOptions = null, + ?CancellationToken $cancellationToken = null, ): AsyncPredictResponse { if (null === $options) { $options = new PredictMethodOptions(); @@ -377,7 +380,7 @@ public function enqueueAndParse( ); error_log("Successfully enqueued document with job id: " . $enqueueResponse->job->id); - $this->customSleep($asyncOptions->initialDelaySec); + $this->customSleep($asyncOptions->initialDelaySec, $cancellationToken); $retryCounter = 1; $pollResults = $this->parseQueued($predictionType, $enqueueResponse->job->id, $options->endpoint); diff --git a/src/V1/Http/BaseEndpoint.php b/src/V1/Http/BaseEndpoint.php index da2b61d6..570ff2d0 100644 --- a/src/V1/Http/BaseEndpoint.php +++ b/src/V1/Http/BaseEndpoint.php @@ -5,6 +5,7 @@ namespace Mindee\V1\Http; use CurlHandle; +use Mindee\Http\CurlSslConfig; /** * Abstract class for endpoints. @@ -38,7 +39,7 @@ protected function initCurlSessionGet(string $queueId): array curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $this->settings->requestTimeout); curl_setopt($ch, CURLOPT_HTTPGET, true); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + CurlSslConfig::apply($ch); curl_setopt($ch, CURLOPT_USERAGENT, getUserAgent()); $resp = [ @@ -72,7 +73,7 @@ public function setFinalCurlOpts( if ($postFields !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, $postFields); } - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + CurlSslConfig::apply($ch); curl_setopt($ch, CURLOPT_USERAGENT, getUserAgent()); $resp = [ 'data' => curl_exec($ch), diff --git a/src/V2/Client.php b/src/V2/Client.php index 25de2e47..9997a827 100644 --- a/src/V2/Client.php +++ b/src/V2/Client.php @@ -7,6 +7,7 @@ use Mindee\ClientOptions\PollingOptions; use Mindee\CustomSleepMixin; use Mindee\Error\MindeeException; +use Mindee\Http\CancellationToken; use Mindee\Input\InputSource; use Mindee\V2\ClientOptions\BaseParameters; use Mindee\V2\Http\MindeeApiV2; @@ -45,7 +46,7 @@ public function __construct(?string $apiKey = null) * @category Asynchronous */ public function enqueue( - InputSource $inputSource, + InputSource $inputSource, BaseParameters $params ): JobResponse { return $this->mindeeApi->reqPostEnqueue($inputSource, $params); @@ -103,14 +104,16 @@ public function getJob(string $jobId): JobResponse * @param InputSource $inputDoc Input document to parse. * @param BaseParameters $params Parameters relating to prediction options. * @param PollingOptions|null $pollingOptions Options to apply to the polling. + * @param CancellationToken|null $cancellationToken CancellationToken to check for cancellation. * @return BaseResponse A response containing parsing results. * @throws MindeeException Throws if enqueueing fails, job fails, or times out. */ public function enqueueAndGetResult( - string $responseClass, - InputSource $inputDoc, - BaseParameters $params, - ?PollingOptions $pollingOptions = null + string $responseClass, + InputSource $inputDoc, + BaseParameters $params, + ?PollingOptions $pollingOptions = null, + ?CancellationToken $cancellationToken = null ): BaseResponse { if (!$pollingOptions) { $pollingOptions = new PollingOptions(); @@ -126,7 +129,7 @@ public function enqueueAndGetResult( $jobId = $enqueueResponse->job->id; error_log("Successfully enqueued document with job ID: " . $jobId); - $this->customSleep($pollingOptions->initialDelaySec); + $this->customSleep($pollingOptions->initialDelaySec, $cancellationToken); $retryCounter = 1; $pollResults = $this->getJob($jobId); @@ -144,7 +147,7 @@ public function enqueueAndGetResult( . ". Job status: " . $pollResults->job->status ); - $this->customSleep($pollingOptions->delaySec); + $this->customSleep($pollingOptions->delaySec, $cancellationToken); $pollResults = $this->getJob($jobId); $retryCounter++; } diff --git a/src/V2/Http/MindeeApiV2.php b/src/V2/Http/MindeeApiV2.php index b1efefc4..6835c1b9 100644 --- a/src/V2/Http/MindeeApiV2.php +++ b/src/V2/Http/MindeeApiV2.php @@ -13,6 +13,7 @@ use Mindee\Error\ErrorCode; use Mindee\Error\MindeeApiException; use Mindee\Error\MindeeException; +use Mindee\Http\CurlSslConfig; use Mindee\Input\InputSource; use Mindee\Input\LocalInputSource; use Mindee\Input\UrlInputSource; @@ -306,7 +307,7 @@ private function initChannel(): bool|CurlHandle curl_setopt($ch, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $this->requestTimeout); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + CurlSslConfig::apply($ch); curl_setopt($ch, CURLOPT_USERAGENT, $this->getUserAgent()); return $ch; diff --git a/tests/Input/UrlInputSourceTest.php b/tests/Input/UrlInputSourceTest.php index c8ce60b5..85fb53db 100644 --- a/tests/Input/UrlInputSourceTest.php +++ b/tests/Input/UrlInputSourceTest.php @@ -39,6 +39,69 @@ 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"; diff --git a/tests/V2/FileOperations/SplitFunctional.php b/tests/V2/FileOperations/SplitFunctional.php index 3b755c2d..78de7d7f 100644 --- a/tests/V2/FileOperations/SplitFunctional.php +++ b/tests/V2/FileOperations/SplitFunctional.php @@ -77,7 +77,9 @@ public function testExtractSplitsFromPdfCorrectly(): void self::assertInstanceof(SplitResponse::class, $response); $extractedSplits = $response->inference->result->extractFromInputSource($inputSource); $extractedSplit0 = $response->inference->result->splits[0]->extractFromInputSource($inputSource); - self::assertEquals($extractedSplit0, $extractedSplits[0]); + self::assertSame($extractedSplit0->filename, $extractedSplits[0]->filename); + self::assertSame($extractedSplit0->pageCount, $extractedSplits[0]->pageCount); + self::assertSame($extractedSplit0->getPdfBytes(), $extractedSplits[0]->getPdfBytes()); self::assertCount(2, $extractedSplits); self::assertSame('default_sample_001-001.pdf', $extractedSplits[0]->filename);