diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index d43e302..1b951dd 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -109,5 +109,5 @@ jobs: if: github.event.inputs.coverage_report == 'true' run: | ddev xdebug - ddev exec XDEBUG_MODE=coverage BOUNCER_KEY=${{ env.BOUNCER_KEY }} AGENT_TLS_PATH=/var/www/html/cfssl LAPI_URL=https://crowdsec:8080 MEMCACHED_DSN=memcached://memcached:11211 REDIS_DSN=redis://redis:6379 /usr/bin/php ./${{env.EXTENSION_PATH}}/tools/coding-standards/vendor/bin/phpunit --configuration ./${{env.EXTENSION_PATH}}/tools/coding-standards/phpunit/phpunit.xml --coverage-text=./${{env.EXTENSION_PATH}}/coding-standards/phpunit/code-coverage/report.txt + ddev exec XDEBUG_MODE=coverage BOUNCER_KEY=${{ env.BOUNCER_KEY }} APPSEC_URL=http://crowdsec:7422 AGENT_TLS_PATH=/var/www/html/cfssl LAPI_URL=https://crowdsec:8080 MEMCACHED_DSN=memcached://memcached:11211 REDIS_DSN=redis://redis:6379 /usr/bin/php ./${{env.EXTENSION_PATH}}/tools/coding-standards/vendor/bin/phpunit --configuration ./${{env.EXTENSION_PATH}}/tools/coding-standards/phpunit/phpunit.xml --coverage-text=./${{env.EXTENSION_PATH}}/coding-standards/phpunit/code-coverage/report.txt cat ${{env.EXTENSION_PATH}}/coding-standards/phpunit/code-coverage/report.txt diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index e884668..7841324 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -82,9 +82,14 @@ jobs: run: | ddev composer update --working-dir ./${{env.EXTENSION_PATH}} + - name: Set excluded groups + id: set-excluded-groups + if: contains(fromJson('["7.2","7.3"]'),matrix.php-version) + run: echo "exclude_group=$(echo --exclude-group up-to-php74 )" >> $GITHUB_OUTPUT + - name: Run "Unit Tests" run: | - ddev exec /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./${{env.EXTENSION_PATH}}/tests/Unit + ddev exec /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox ${{ steps.set-excluded-groups.outputs.exclude_group }} ./${{env.EXTENSION_PATH}}/tests/Unit - name: Prepare PHP Integration tests run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 25241e3..35c15ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,16 @@ As far as possible, we try to adhere to [Symfony guidelines](https://symfony.com --- +## [3.2.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v3.2.0) - 2024-10-23 +[_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v3.1.0...v3.2.0) + + +### Added + +- Add protected `buildRequestRawBody` helper method to `AbstractBouncer` class + +--- + ## [3.1.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v3.1.0) - 2024-10-18 [_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v3.0.0...v3.1.0) diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md index 3b5c160..dbc00b0 100644 --- a/docs/DEVELOPER.md +++ b/docs/DEVELOPER.md @@ -286,9 +286,7 @@ ddev xdebug To generate a html report, you can run: ```bash -ddev exec XDEBUG_MODE=coverage APPSEC_URL=http://crowdsec:7422 BOUNCER_KEY=your-bouncer-key -AGENT_TLS_PATH=/var/www/html/cfssl LAPI_URL=https://crowdsec:8080 -REDIS_DSN=redis://redis:6379 MEMCACHED_DSN=memcached://memcached:11211 /usr/bin/php ./my-code/crowdsec-bouncer-lib/tools/coding-standards/vendor/bin/phpunit --configuration ./my-code/crowdsec-bouncer-lib/tools/coding-standards/phpunit/phpunit.xml +ddev exec XDEBUG_MODE=coverage APPSEC_URL=http://crowdsec:7422 BOUNCER_KEY=your-bouncer-key AGENT_TLS_PATH=/var/www/html/cfssl LAPI_URL=https://crowdsec:8080 REDIS_DSN=redis://redis:6379 MEMCACHED_DSN=memcached://memcached:11211 /usr/bin/php ./my-code/crowdsec-bouncer-lib/tools/coding-standards/vendor/bin/phpunit --configuration ./my-code/crowdsec-bouncer-lib/tools/coding-standards/phpunit/phpunit.xml ``` diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index e78587b..c18eff4 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -134,6 +134,38 @@ class MyCustomBouncer extends AbstractBouncer { // Your implementation } + + /** + * Get current request headers + */ + public function getRequestHeaders(): array + { + // Your implementation + } + + /** + * Get the raw body of the current request + */ + public function getRequestRawBody(): string + { + // Your implementation + } + + /** + * Get the host of the current request + */ + public function getRequestHost() : string + { + // Your implementation + } + + /** + * Get the user agent of the current request + */ + public function getRequestUserAgent() : string + { + // Your implementation + } } ``` diff --git a/src/AbstractBouncer.php b/src/AbstractBouncer.php index eb0a64b..962c60b 100644 --- a/src/AbstractBouncer.php +++ b/src/AbstractBouncer.php @@ -36,6 +36,8 @@ */ abstract class AbstractBouncer { + use Helper; + /** @var array */ protected $configs = []; /** @var LoggerInterface */ @@ -326,6 +328,41 @@ public function testCacheConnection(): void } } + /** + * Method based on superglobals to retrieve the raw body of the request. + * If the body is too big (greater than the "appsec_max_body_size_kb" configuration), + * it will be truncated to the maximum size + 1 kB. + * In case of error, an empty string is returned. + * + * @param resource $stream The stream to read the body from + * + * @see https://www.php.net/manual/en/language.variables.superglobals.php + */ + protected function buildRequestRawBody($stream): string + { + if (!is_resource($stream)) { + $this->logger->error('Invalid stream resource', [ + 'type' => 'BUILD_RAW_BODY', + ]); + + return ''; + } + $maxBodySize = $this->getRemediationEngine()->getConfig('appsec_max_body_size_kb') ?? + Constants::APPSEC_DEFAULT_MAX_BODY_SIZE; + + try { + return $this->buildRawBodyFromSuperglobals($maxBodySize, $stream, $_SERVER, $_POST, $_FILES); + } catch (BouncerException $e) { + $this->logger->error('Error while building raw body', [ + 'type' => 'BUILD_RAW_BODY', + 'message' => $e->getMessage(), + 'code' => $e->getCode(), + ]); + + return ''; + } + } + /** * Returns a default "CrowdSec 403" HTML template. * The input $config should match the TemplateConfiguration input format. diff --git a/src/Constants.php b/src/Constants.php index f4f5b1f..144a7db 100644 --- a/src/Constants.php +++ b/src/Constants.php @@ -37,7 +37,7 @@ class Constants extends RemConstants /** @var string Path for html templates folder (e.g. ban and captcha wall) */ public const TEMPLATES_DIR = __DIR__ . '/templates'; /** @var string The last version of this library */ - public const VERSION = 'v3.1.0'; + public const VERSION = 'v3.2.0'; /** @var string The "disabled" x-forwarded-for setting */ public const X_FORWARDED_DISABLED = 'no_forward'; } diff --git a/src/Helper.php b/src/Helper.php new file mode 100644 index 0000000..b29628b --- /dev/null +++ b/src/Helper.php @@ -0,0 +1,223 @@ +getMultipartRawBody($contentType, $sizeThreshold, $postData, $filesData); + } + + return $this->getRawInput($sizeThreshold, $stream); + } + + private function appendFileData( + array $fileArray, + ?int $index, + string $fileKey, + string $boundary, + int $threshold, + int &$currentSize + ): string { + $fileName = is_array($fileArray['name']) ? $fileArray['name'][$index] : $fileArray['name']; + $fileTmpName = is_array($fileArray['tmp_name']) ? $fileArray['tmp_name'][$index] : $fileArray['tmp_name']; + $fileType = is_array($fileArray['type']) ? $fileArray['type'][$index] : $fileArray['type']; + + $headerPart = '--' . $boundary . "\r\n"; + $headerPart .= "Content-Disposition: form-data; name=\"$fileKey\"; filename=\"$fileName\"\r\n"; + $headerPart .= "Content-Type: $fileType\r\n\r\n"; + + $currentSize += strlen($headerPart); + if ($currentSize >= $threshold) { + return substr($headerPart, 0, $threshold - ($currentSize - strlen($headerPart))); + } + + $remainingSize = $threshold - $currentSize; + $fileStream = fopen($fileTmpName, 'rb'); + $fileContent = $this->readStream($fileStream, $remainingSize); + // Add 2 bytes for the \r\n at the end of the file content + $currentSize += strlen($fileContent) + 2; + + return $headerPart . $fileContent . "\r\n"; + } + + private function buildFormData(string $boundary, string $key, string $value): string + { + return '--' . $boundary . "\r\n" . + "Content-Disposition: form-data; name=\"$key\"\r\n\r\n" . + "$value\r\n"; + } + + /** + * Extract the boundary from the Content-Type. + * + * Regex breakdown: + * /boundary="?([^;"]+)"?/i + * + * - boundary= : Matches the literal string 'boundary=' which indicates the start of the boundary parameter. + * - "? : Matches an optional double quote that may surround the boundary value. + * - ([^;"]+) : Captures one or more characters that are not a semicolon (;) or a double quote (") into a group. + * This ensures the boundary is extracted accurately, stopping at a semicolon if present, + * and avoiding the inclusion of quotes in the captured value. + * - "? : Matches an optional closing double quote (if the boundary is quoted). + * - i : Case-insensitive flag to handle 'boundary=' in any case (e.g., 'Boundary=' or 'BOUNDARY='). + * + * @throws BouncerException + */ + private function extractBoundary(string $contentType): string + { + if (preg_match('/boundary="?([^;"]+)"?/i', $contentType, $matches)) { + return trim($matches[1]); + } + throw new BouncerException("Failed to extract boundary from Content-Type: ($contentType)"); + } + + /** + * Return the raw body for multipart requests. + * This method will read the raw body up to the specified threshold. + * If the body is too large, it will return a truncated version of the body up to the threshold. + * + * @throws BouncerException + */ + private function getMultipartRawBody( + string $contentType, + int $threshold, + array $postData, + array $filesData + ): string { + try { + $boundary = $this->extractBoundary($contentType); + // Instead of concatenating strings, we will use an array to store the parts + // and then join them with implode at the end to avoid performance issues. + $parts = []; + $currentSize = 0; + + foreach ($postData as $key => $value) { + $formData = $this->buildFormData($boundary, $key, $value); + $currentSize += strlen($formData); + if ($currentSize >= $threshold) { + return substr(implode('', $parts) . $formData, 0, $threshold); + } + + $parts[] = $formData; + } + + foreach ($filesData as $fileKey => $fileArray) { + $fileNames = is_array($fileArray['name']) ? $fileArray['name'] : [$fileArray['name']]; + foreach ($fileNames as $index => $fileName) { + $remainingSize = $threshold - $currentSize; + $fileData = + $this->appendFileData($fileArray, $index, $fileKey, $boundary, $remainingSize, $currentSize); + if ($currentSize >= $threshold) { + return substr(implode('', $parts) . $fileData, 0, $threshold); + } + $parts[] = $fileData; + } + } + + $endBoundary = '--' . $boundary . "--\r\n"; + $currentSize += strlen($endBoundary); + + if ($currentSize >= $threshold) { + return substr(implode('', $parts) . $endBoundary, 0, $threshold); + } + + $parts[] = $endBoundary; + + return implode('', $parts); + } catch (\Throwable $e) { + throw new BouncerException('Failed to read multipart raw body: ' . $e->getMessage()); + } + } + + private function getRawInput(int $threshold, $stream): string + { + return $this->readStream($stream, $threshold); + } + + /** + * Read the stream up to the specified threshold. + * + * @param resource $stream The stream to read + * @param int $threshold The maximum number of bytes to read + * + * @throws BouncerException + */ + private function readStream($stream, int $threshold): string + { + if (!is_resource($stream)) { + throw new BouncerException('Stream is not a valid resource'); + } + $buffer = ''; + $chunkSize = 8192; + $bytesRead = 0; + // We make sure there won't be infinite loop + $maxLoops = (int) ceil($threshold / $chunkSize); + $loopCount = -1; + + try { + while (!feof($stream) && $bytesRead < $threshold) { + ++$loopCount; + if ($loopCount >= $maxLoops) { + throw new BouncerException("Too many loops ($loopCount) while reading stream"); + } + $remainingSize = $threshold - $bytesRead; + $readLength = min($chunkSize, $remainingSize); + + $data = fread($stream, $readLength); + if (false === $data) { + throw new BouncerException('Failed to read chunk from stream'); + } + + $buffer .= $data; + $bytesRead += strlen($data); + + if ($bytesRead >= $threshold) { + break; + } + } + + return $buffer; + } catch (\Throwable $e) { + throw new BouncerException('Failed to read stream: ' . $e->getMessage()); + } finally { + fclose($stream); + } + } +} diff --git a/tests/Integration/AbstractBouncerTest.php b/tests/Integration/AbstractBouncerTest.php index 1d43393..f933fcc 100644 --- a/tests/Integration/AbstractBouncerTest.php +++ b/tests/Integration/AbstractBouncerTest.php @@ -244,6 +244,7 @@ public function testCaptchaFlow() // Step 1 : access a page should display a captcha wall $bouncer->bounceCurrentIp(); + usleep(200 * 1000); // wait for cache to be written $item = $cache->getItem($cacheKey); $this->assertEquals( @@ -471,7 +472,7 @@ public function testAppSecFlow() $originCountItem = $cache->getItem(AbstractCache::ORIGINS_COUNT)->get(); if ($this->useTls) { - $this->assertArrayNotHasKey('appsec', $originCountItem, 'The origin count for appsec should not be present'); + $this->assertArrayNotHasKey('appsec', $originCountItem, 'The origin count for appsec should not be present'); } else { $this->assertEquals( 1, @@ -1191,8 +1192,8 @@ public function testCanVerifyIpInLiveMode(): void */ public function testCanVerifyIpInStreamMode(): void { - - $this->logger = new ConsoleLog(); + // Uncomment the below line to see debug log in console + // $this->logger = new ConsoleLog(); // Init context $this->watcherClient->setInitialState(); // Init bouncer @@ -1236,7 +1237,7 @@ public function testCanVerifyIpInStreamMode(): void $cappedRemediation = $bouncer->getRemediationForIp(TestHelpers::BAD_IP); $this->assertEquals('captcha', $cappedRemediation, 'The remediation for the banned IP should now be "captcha"'); $bouncerConfigs['bouncing_level'] = Constants::BOUNCING_LEVEL_NORMAL; - $client = new BouncerClient($bouncerConfigs,null, $this->logger); + $client = new BouncerClient($bouncerConfigs, null, $this->logger); $cache = new PhpFiles($bouncerConfigs); $lapiRemediation = new LapiRemediation($bouncerConfigs, $client, $cache, $this->logger); $bouncer = $this->getMockForAbstractClass(AbstractBouncer::class, [$bouncerConfigs, $lapiRemediation]); @@ -1284,7 +1285,7 @@ public function testCanVerifyIpInStreamMode(): void $bouncerConfigs['tls_verify_peer'] = true; } - $client = new BouncerClient($bouncerConfigs, null, $this->logger); + $client = new BouncerClient($bouncerConfigs, null, $this->logger); $cache = new PhpFiles($bouncerConfigs); $lapiRemediation = new LapiRemediation($bouncerConfigs, $client, $cache, $this->logger); $bouncer = $this->getMockForAbstractClass(AbstractBouncer::class, [$bouncerConfigs, $lapiRemediation]); diff --git a/tests/Integration/WatcherClient.php b/tests/Integration/WatcherClient.php index 9c3b07f..e9e0af8 100644 --- a/tests/Integration/WatcherClient.php +++ b/tests/Integration/WatcherClient.php @@ -66,12 +66,11 @@ private function manageRequest( public function setInitialState(): void { $this->deleteAllDecisions(); - sleep(1); $now = new \DateTime(); $this->addDecision($now, '12h', '+12 hours', TestHelpers::BAD_IP, 'captcha'); $this->addDecision($now, '24h', self::HOURS24, TestHelpers::BAD_IP . '/' . TestHelpers::IP_RANGE, 'ban'); $this->addDecision($now, '24h', '+24 hours', TestHelpers::JAPAN, 'captcha', Constants::SCOPE_COUNTRY); - sleep(1); + usleep(500 * 1000); } /** Set the second watcher state */ @@ -79,7 +78,6 @@ public function setSecondState(): void { $this->logger->info('', ['message' => 'Set "second" state']); $this->deleteAllDecisions(); - sleep(1); $now = new \DateTime(); $this->addDecision($now, '36h', '+36 hours', TestHelpers::NEWLY_BAD_IP, 'ban'); $this->addDecision( @@ -92,7 +90,7 @@ public function setSecondState(): void $this->addDecision($now, '24h', self::HOURS24, TestHelpers::JAPAN, 'captcha', Constants::SCOPE_COUNTRY); $this->addDecision($now, '24h', self::HOURS24, TestHelpers::IP_JAPAN, 'ban'); $this->addDecision($now, '24h', self::HOURS24, TestHelpers::IP_FRANCE, 'ban'); - sleep(1); + usleep(500 * 1000); } public function setSimpleDecision(string $type = 'ban'): void diff --git a/tests/Unit/AbstractBouncerTest.php b/tests/Unit/AbstractBouncerTest.php index 6e13541..62dcf92 100644 --- a/tests/Unit/AbstractBouncerTest.php +++ b/tests/Unit/AbstractBouncerTest.php @@ -53,6 +53,7 @@ * @covers \CrowdSecBouncer\AbstractBouncer::getTrustForwardedIpBoundsList * @covers \CrowdSecBouncer\AbstractBouncer::handleForwardedFor * @covers \CrowdSecBouncer\AbstractBouncer::shouldUseAppSec + * @covers \CrowdSecBouncer\AbstractBouncer::buildRequestRawBody * * @uses \CrowdSecBouncer\AbstractBouncer::handleRemediation * @@ -69,6 +70,14 @@ * @covers \CrowdSecBouncer\AbstractBouncer::pruneCache * @covers \CrowdSecBouncer\AbstractBouncer::refreshBlocklistCache * @covers \CrowdSecBouncer\AbstractBouncer::testCacheConnection + * + * @uses \CrowdSecBouncer\Helper::buildFormData + * @uses \CrowdSecBouncer\Helper::buildRawBodyFromSuperglobals + * @uses \CrowdSecBouncer\Helper::extractBoundary + * @uses \CrowdSecBouncer\Helper::getMultipartRawBody + * @uses \CrowdSecBouncer\Helper::getRawInput + * @uses \CrowdSecBouncer\Helper::readStream + * @uses \CrowdSecBouncer\Helper::appendFileData */ final class AbstractBouncerTest extends TestCase { @@ -576,6 +585,204 @@ public function testPrivateAndProtectedMethods() $this->assertEquals(false, $result, 'Should return false if ip is invalid'); } + /** + * @group test + */ + public function testBuildRequestRawbody() + { + $configs = $this->configs; + $mockRemediation = $this->getMockBuilder(LapiRemediation::class) + ->disableOriginalConstructor() + ->onlyMethods(['getConfig']) + ->getMock(); + + $mockRemediation->method('getConfig')->willReturnOnConsecutiveCalls( + null, // Return null on the first call + 1 // Return 1 on all subsequent calls + ); + + // test 1: bad resource + $bouncer = $this->getMockForAbstractClass(AbstractBouncer::class, [$configs, $mockRemediation]); + + $result = PHPUnitUtil::callMethod( + $bouncer, + 'buildRequestRawBody', + [1024, 'bad-resource'] + ); + $this->assertEquals('', $result, 'Should return an empty string for unvalid resource'); + + // Test 2: resource is a ok (and use default value for appsec_max_body_size_kb) + $mockRemediation->method('getConfig')->will( + $this->returnValueMap( + [ + ['appsec_max_body_size_kb', 1], + ] + ) + ); + $streamType = 'php://memory'; + $inputStream = fopen($streamType, 'r+'); + fwrite($inputStream, '{"key": "value"}'); + rewind($inputStream); + + $result = PHPUnitUtil::callMethod( + $bouncer, + 'buildRequestRawBody', + [$inputStream] + ); + + $this->assertEquals('{"key": "value"}', $result, 'Should return the content of the stream'); + // Test 3: multipart/form-data (and use 1 for appsec_max_body_size_kb) + $mockRemediation->method('getConfig')->will( + $this->returnValueMap( + [ + ['appsec_max_body_size_kb', null], + ] + ) + ); + $bouncer = $this->getMockForAbstractClass(AbstractBouncer::class, [$configs, $mockRemediation]); + $inputStream = fopen($streamType, 'r+'); + fwrite($inputStream, '{"key": "value"}'); + rewind($inputStream); + $_SERVER['CONTENT_TYPE'] = 'multipart/form-data; boundary="----WebKitFormBoundary7MA4YWxkTrZu0gW"'; + $_POST = ['key' => 'value']; + $result = PHPUnitUtil::callMethod( + $bouncer, + 'buildRequestRawBody', + [$inputStream] + ); + + $expected = <<assertEquals($expected, $result, 'Should return the posted data'); + + // Test 4: multipart with no boundary + $inputStream = fopen($streamType, 'r+'); + fwrite($inputStream, '{"key": "value"}'); + rewind($inputStream); + $_SERVER['CONTENT_TYPE'] = 'multipart/form-data'; + + $result = PHPUnitUtil::callMethod( + $bouncer, + 'buildRequestRawBody', + [$inputStream] + ); + + $this->assertEquals('', $result, 'Should return empty as there is no usable boundary'); + + // Test 5: multipart with one file + $inputStream = fopen($streamType, 'r+'); + fwrite($inputStream, '{"key": "value"}'); + rewind($inputStream); + + $_SERVER['CONTENT_TYPE'] = 'multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW; charset=UTF-8'; + $_POST = []; + + file_put_contents($this->root->url() . '/tmp1', 'THIS_IS_THE_FILE_1_CONTENT'); + + $_FILES = [ + 'file' => [ + 'name' => 'test.txt', + 'type' => 'text/plain', + 'tmp_name' => $this->root->url() . '/tmp1', + 'error' => 0, + 'size' => 1024, + ], + ]; + + $result = PHPUnitUtil::callMethod( + $bouncer, + 'buildRequestRawBody', + [$inputStream] + ); + + $expected = <<assertEquals($expected, $result, 'Should return the data with the file'); + // Test 6: multipart with multiple files + $inputStream = fopen($streamType, 'r+'); + fwrite($inputStream, '{"key": "value"}'); + rewind($inputStream); + $_SERVER['CONTENT_TYPE'] = 'multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW'; + $_POST = []; + file_put_contents($this->root->url() . '/tmp1', 'THIS_IS_THE_FILE_1_CONTENT'); + file_put_contents($this->root->url() . '/tmp2', 'THIS_IS_THE_FILE_2_CONTENT'); + file_put_contents($this->root->url() . '/tmp3', 'THIS_IS_THE_FILE_3_CONTENT'); + $_FILES = [ + 'file' => [ + 'name' => [ + 0 => 'image1.jpg', + 1 => 'image2.jpg', + 2 => 'image3.png', + ], + 'type' => [ + 0 => 'image/jpeg', + 1 => 'image/jpeg', + 2 => 'image/png', + ], + 'tmp_name' => [ + 0 => $this->root->url() . '/tmp1', + 1 => $this->root->url() . '/tmp2', + 2 => $this->root->url() . '/tmp3', + ], + 'error' => [ + 0 => 0, + 1 => 0, + 2 => 0, + ], + 'size' => [ + 0 => 12345, + 1 => 54321, + 2 => 67890, + ], + ], + ]; + $result = PHPUnitUtil::callMethod( + $bouncer, + 'buildRequestRawBody', + [$inputStream] + ); + + $expected = <<assertEquals($expected, $result, 'Should return the data with the files'); + } + public function testGetRemediationForIpException() { $configs = $this->configs; diff --git a/tests/Unit/FailingStreamWrapper.php b/tests/Unit/FailingStreamWrapper.php new file mode 100644 index 0000000..6daf252 --- /dev/null +++ b/tests/Unit/FailingStreamWrapper.php @@ -0,0 +1,33 @@ +root->url() . '/tmp-test.txt', 'THIS IS A TEST FILE'); + + $serverData = ['CONTENT_TYPE' => 'multipart/form-data; badstring=----WebKitFormBoundary']; + $postData = ['key' => 'value']; + $filesData = ['file' => ['name' => 'test.txt', 'tmp_name' => $this->root->url() . '/tmp-test.txt', 'type' => 'text/plain']]; + $maxBodySize = 1; + + $error = ''; + + try { + $this->buildRawBodyFromSuperglobals($maxBodySize, $inputStream, $serverData, $postData, $filesData); + } catch (CrowdSecBouncer\BouncerException $e) { + $error = $e->getMessage(); + } + $this->assertEquals('Failed to read multipart raw body: Failed to extract boundary from Content-Type: (multipart/form-data; badstring=----WebKitFormBoundary)', $error); + } + + public function testBuildRawBodyFromSuperglobalsWithEmptyContentTypeReturnsRawInput() + { + $serverData = []; + $streamType = 'php://memory'; + $inputStream = fopen($streamType, 'r+'); + fwrite($inputStream, '{"key": "value"}'); + rewind($inputStream); + $maxBodySize = 1; + + $result = $this->buildRawBodyFromSuperglobals($maxBodySize, $inputStream, $serverData, [], []); + + $this->assertEquals('{"key": "value"}', $result); + } + + public function testBuildRawBodyFromSuperglobalsWithLargeBodyTruncatesBody() + { + $serverData = ['CONTENT_TYPE' => 'application/json']; + $streamType = 'php://memory'; + $inputStream = fopen($streamType, 'r+'); + fwrite($inputStream, str_repeat('a', 2048)); + rewind($inputStream); + $maxBodySize = 1; + + $result = $this->buildRawBodyFromSuperglobals($maxBodySize, $inputStream, $serverData, [], []); + + $this->assertEquals(str_repeat('a', 1025), $result); + } + + public function testBuildRawBodyFromSuperglobalsWithMultipartContentTypeReturnsMultipartRawBody() + { + file_put_contents($this->root->url() . '/tmp-test.txt', 'THIS IS A TEST FILE'); + + $serverData = ['CONTENT_TYPE' => 'multipart/form-data; boundary=----WebKitFormBoundary; charset=UTF-8']; + $postData = ['key' => 'value']; + $filesData = ['file' => ['name' => 'test.txt', 'tmp_name' => $this->root->url() . '/tmp-test.txt', 'type' => 'text/plain']]; + $maxBodySize = 1; + + $result = $this->buildRawBodyFromSuperglobals($maxBodySize, null, $serverData, $postData, $filesData); + + $this->assertStringContainsString('Content-Disposition: form-data; name="key"', $result); + $this->assertStringContainsString('Content-Disposition: form-data; name="file"; filename="test.txt"', $result); + + $this->assertEquals(248, strlen($result)); + $this->assertStringContainsString('THIS IS A TEST FILE', $result); + } + + public function testBuildRawBodyFromSuperglobalsWithNoStreamShouldThrowException() + { + $serverData = ['CONTENT_TYPE' => 'application/json']; + $streamType = 'php://temp'; + $inputStream = fopen($streamType, 'r+'); + fwrite($inputStream, '{"key": "value"}'); + // We are closing the stream so it becomes unavailable + fclose($inputStream); + $maxBodySize = 15; + + $error = ''; + try { + $this->buildRawBodyFromSuperglobals($maxBodySize, $inputStream, $serverData, [], []); + } catch (CrowdSecBouncer\BouncerException $e) { + $error = $e->getMessage(); + } + + $this->assertEquals('Stream is not a valid resource', $error); + } + + public function testBuildRawBodyFromSuperglobalsWithNonMultipartContentTypeReturnsRawInput() + { + $serverData = ['CONTENT_TYPE' => 'application/json']; + $streamType = 'php://memory'; + $inputStream = fopen($streamType, 'r+'); + fwrite($inputStream, '{"key": "value"}'); + rewind($inputStream); + $maxBodySize = 15; + + $result = $this->buildRawBodyFromSuperglobals($maxBodySize, $inputStream, $serverData, [], []); + + $this->assertEquals('{"key": "value"}', $result); + } + + public function testGetMultipartRawBodyWithLargeFileDataShouldThrowException() + { + $contentType = 'multipart/form-data; BOUNDARY=----WebKitFormBoundary'; + $postData = []; + $filesData = ['file' => ['name' => 'test.txt', 'tmp_name' => $this->root->url() . '/phpYzdqkD', 'type' => 'text/plain']]; + // We don't create the file so it will throw an exception + + $error = ''; + try { + $this->getMultipartRawBody($contentType, 1025, $postData, $filesData); + } catch (CrowdSecBouncer\BouncerException $e) { + $error = $e->getMessage(); + } + + $this->assertStringContainsString('Failed to read multipart raw body', $error); + $this->assertStringContainsString('fopen(vfs://tmp/phpYzdqkD)', $error); + } + + public function testGetMultipartRawBodyWithLargeFileDataTruncatesBody() + { + $contentType = 'multipart/form-data; bOuNdary="----WebKitFormBoundary"'; + $postData = []; + $filesData = ['file' => ['name' => 'test.txt', 'tmp_name' => $this->root->url() . '/phpYzdqkD', 'type' => 'text/plain']]; + file_put_contents($this->root->url() . '/phpYzdqkD', 'THIS_IS_THE_CONTENT' . str_repeat('a', 2048)); + $threshold = 1025; + + $result = $this->getMultipartRawBody($contentType, $threshold, $postData, $filesData); + + $this->assertEquals(1025, strlen($result)); + $this->assertStringContainsString('THIS_IS_THE_CONTENT', $result); + } + + public function testGetMultipartRawBodyWithLargeFileDataTruncatesBodyEnBoundary() + { + $contentType = 'multipart/form-data; boundary=----WebKitFormBoundary'; + $postData = []; + $filesData = ['file' => ['name' => 'test.txt', 'tmp_name' => $this->root->url() . '/phpYzdqkD', 'type' => 'text/plain']]; + file_put_contents($this->root->url() . '/phpYzdqkD', str_repeat('a', 2045)); + // Total size without adding boundary is 2167 + $threshold = 2168; + + $result = $this->getMultipartRawBody($contentType, $threshold, $postData, $filesData); + + $this->assertEquals(2168, strlen($result)); + } + + /** + * @group unit + */ + public function testGetMultipartRawBodyWithLargeFileNameTruncatesBody() + { + $contentType = 'multipart/form-data; boundary=----WebKitFormBoundary'; + $postData = []; + $filesData = ['file' => ['name' => str_repeat('a', 2048) . '.txt', 'tmp_name' => $this->root->url() . '/phpYzdqkD', 'type' => 'text/plain']]; + file_put_contents($this->root->url() . '/phpYzdqkD', 'THIS_IS_THE_CONTENT'); + $threshold = 1025; + + $result = $this->getMultipartRawBody($contentType, $threshold, $postData, $filesData); + + $this->assertEquals(1025, strlen($result)); + $this->assertStringNotContainsString('THIS_IS_THE_CONTENT', $result); + } + + public function testGetMultipartRawBodyWithLargePostDataTruncatesBody() + { + $contentType = 'multipart/form-data; boundary=----WebKitFormBoundary'; + $postData = ['key' => str_repeat('a', 2048)]; + $filesData = []; + $threshold = 1025; + + $result = $this->getMultipartRawBody($contentType, $threshold, $postData, $filesData); + + $this->assertEquals(1025, strlen($result)); + $this->assertStringContainsString('Content-Disposition: form-data; name="key"', $result); + $this->assertStringContainsString(str_repeat('a', 953), $result); + } + + /** + * @group up-to-php74 + * Before PHP 7.4, fread can fail without returning false, leading to an infinite loop. + * + * @see https://bugs.php.net/bug.php?id=79965 + */ + public function testReadStreamWithFreadFailureShouldThrowException() + { + // Register custom stream wrapper that fails on fread + stream_wrapper_register('failing', FailingStreamWrapper::class); + FailingStreamWrapper::$eofResult = false; + FailingStreamWrapper::$readResult = false; + + // Open a stream using the failing stream wrapper + $mockStream = fopen('failing://test', 'r+'); + + // Set the threshold (can be any number) + $threshold = 100; + + $error = ''; + try { + $this->readStream($mockStream, $threshold); + } catch (CrowdSecBouncer\BouncerException $e) { + $error = $e->getMessage(); + } + + // Assert that the correct exception message was thrown + $this->assertStringStartsWith('Failed to read stream: Failed to read chunk from stream', $error); + + // Clean up the custom stream wrapper + stream_wrapper_unregister('failing'); + } + + /** + * @group infinite-loop + */ + public function testReadStreamShouldNotInfiniteLoop() + { + // Register custom stream wrapper that will read forever + stream_wrapper_register('failing', FailingStreamWrapper::class); + FailingStreamWrapper::$eofResult = false; + FailingStreamWrapper::$readResult = ''; + + // Open a stream using the failing stream wrapper + $mockStream = fopen('failing://test', 'r+'); + + // Set the threshold (can be any number) + $threshold = 8192 * 3; + + $error = ''; + try { + $this->readStream($mockStream, $threshold); + } catch (CrowdSecBouncer\BouncerException $e) { + $error = $e->getMessage(); + } + + // Assert that the correct exception message was thrown + $this->assertStringStartsWith('Failed to read stream: Too many loops (3) while reading stream', $error); + + // Clean up the custom stream wrapper + stream_wrapper_unregister('failing'); + } + + protected function setUp(): void + { + $this->root = vfsStream::setup('/tmp'); + } +}