Skip to content

Commit 68e0c83

Browse files
feat(*): Add buildRequestRawBody helper (#133)
* feat(bouncer): Add buildRequestrawBody helper * style(*): Pass through code format tools * feat(bouncer): Avoid infinite loop in buildRequestrawBody * ci(test): Exclude some test for php < 7.4 * feat(bouncer): Improve infinite loop security log message * feat(*): Prepare release 3.2.0 * feat(bouncer): Improve boundary extraction * style(*): Remove trailing commas for php 7
1 parent 0b4c021 commit 68e0c83

13 files changed

+839
-15
lines changed

.github/workflows/coding-standards.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,5 +109,5 @@ jobs:
109109
if: github.event.inputs.coverage_report == 'true'
110110
run: |
111111
ddev xdebug
112-
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
112+
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
113113
cat ${{env.EXTENSION_PATH}}/coding-standards/phpunit/code-coverage/report.txt

.github/workflows/test-suite.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,14 @@ jobs:
8282
run: |
8383
ddev composer update --working-dir ./${{env.EXTENSION_PATH}}
8484
85+
- name: Set excluded groups
86+
id: set-excluded-groups
87+
if: contains(fromJson('["7.2","7.3"]'),matrix.php-version)
88+
run: echo "exclude_group=$(echo --exclude-group up-to-php74 )" >> $GITHUB_OUTPUT
89+
8590
- name: Run "Unit Tests"
8691
run: |
87-
ddev exec /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group ignore ./${{env.EXTENSION_PATH}}/tests/Unit
92+
ddev exec /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox ${{ steps.set-excluded-groups.outputs.exclude_group }} ./${{env.EXTENSION_PATH}}/tests/Unit
8893
8994
- name: Prepare PHP Integration tests
9095
run: |

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,16 @@ As far as possible, we try to adhere to [Symfony guidelines](https://symfony.com
1111

1212
---
1313

14+
## [3.2.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v3.2.0) - 2024-10-23
15+
[_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v3.1.0...v3.2.0)
16+
17+
18+
### Added
19+
20+
- Add protected `buildRequestRawBody` helper method to `AbstractBouncer` class
21+
22+
---
23+
1424
## [3.1.0](https://github.com/crowdsecurity/php-cs-bouncer/releases/tag/v3.1.0) - 2024-10-18
1525
[_Compare with previous release_](https://github.com/crowdsecurity/php-cs-bouncer/compare/v3.0.0...v3.1.0)
1626

docs/DEVELOPER.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -286,9 +286,7 @@ ddev xdebug
286286

287287
To generate a html report, you can run:
288288
```bash
289-
ddev exec XDEBUG_MODE=coverage APPSEC_URL=http://crowdsec:7422 BOUNCER_KEY=your-bouncer-key
290-
AGENT_TLS_PATH=/var/www/html/cfssl LAPI_URL=https://crowdsec:8080
291-
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
289+
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
292290

293291
```
294292

docs/USER_GUIDE.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,38 @@ class MyCustomBouncer extends AbstractBouncer
134134
{
135135
// Your implementation
136136
}
137+
138+
/**
139+
* Get current request headers
140+
*/
141+
public function getRequestHeaders(): array
142+
{
143+
// Your implementation
144+
}
145+
146+
/**
147+
* Get the raw body of the current request
148+
*/
149+
public function getRequestRawBody(): string
150+
{
151+
// Your implementation
152+
}
153+
154+
/**
155+
* Get the host of the current request
156+
*/
157+
public function getRequestHost() : string
158+
{
159+
// Your implementation
160+
}
161+
162+
/**
163+
* Get the user agent of the current request
164+
*/
165+
public function getRequestUserAgent() : string
166+
{
167+
// Your implementation
168+
}
137169

138170
}
139171
```

src/AbstractBouncer.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
*/
3737
abstract class AbstractBouncer
3838
{
39+
use Helper;
40+
3941
/** @var array */
4042
protected $configs = [];
4143
/** @var LoggerInterface */
@@ -326,6 +328,41 @@ public function testCacheConnection(): void
326328
}
327329
}
328330

331+
/**
332+
* Method based on superglobals to retrieve the raw body of the request.
333+
* If the body is too big (greater than the "appsec_max_body_size_kb" configuration),
334+
* it will be truncated to the maximum size + 1 kB.
335+
* In case of error, an empty string is returned.
336+
*
337+
* @param resource $stream The stream to read the body from
338+
*
339+
* @see https://www.php.net/manual/en/language.variables.superglobals.php
340+
*/
341+
protected function buildRequestRawBody($stream): string
342+
{
343+
if (!is_resource($stream)) {
344+
$this->logger->error('Invalid stream resource', [
345+
'type' => 'BUILD_RAW_BODY',
346+
]);
347+
348+
return '';
349+
}
350+
$maxBodySize = $this->getRemediationEngine()->getConfig('appsec_max_body_size_kb') ??
351+
Constants::APPSEC_DEFAULT_MAX_BODY_SIZE;
352+
353+
try {
354+
return $this->buildRawBodyFromSuperglobals($maxBodySize, $stream, $_SERVER, $_POST, $_FILES);
355+
} catch (BouncerException $e) {
356+
$this->logger->error('Error while building raw body', [
357+
'type' => 'BUILD_RAW_BODY',
358+
'message' => $e->getMessage(),
359+
'code' => $e->getCode(),
360+
]);
361+
362+
return '';
363+
}
364+
}
365+
329366
/**
330367
* Returns a default "CrowdSec 403" HTML template.
331368
* The input $config should match the TemplateConfiguration input format.

src/Constants.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ class Constants extends RemConstants
3737
/** @var string Path for html templates folder (e.g. ban and captcha wall) */
3838
public const TEMPLATES_DIR = __DIR__ . '/templates';
3939
/** @var string The last version of this library */
40-
public const VERSION = 'v3.1.0';
40+
public const VERSION = 'v3.2.0';
4141
/** @var string The "disabled" x-forwarded-for setting */
4242
public const X_FORWARDED_DISABLED = 'no_forward';
4343
}

src/Helper.php

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace CrowdSecBouncer;
6+
7+
/**
8+
* Helper trait for Bouncer.
9+
*
10+
* @author CrowdSec team
11+
*
12+
* @see https://crowdsec.net CrowdSec Official Website
13+
*
14+
* @copyright Copyright (c) 2021+ CrowdSec
15+
* @license MIT License
16+
*/
17+
trait Helper
18+
{
19+
/**
20+
* Build the raw body from superglobals.
21+
*
22+
* @param int $maxBodySize the maximum body size in KB
23+
* @param resource $stream The stream to read
24+
* @param array $serverData the $_SERVER superglobal
25+
* @param array $postData the $_POST superglobal
26+
* @param array $filesData the $_FILES superglobal
27+
*
28+
* @return string the raw body
29+
*
30+
* @throws BouncerException
31+
*/
32+
private function buildRawBodyFromSuperglobals(
33+
int $maxBodySize,
34+
$stream,
35+
array $serverData = [], // $_SERVER
36+
array $postData = [], // $_POST
37+
array $filesData = [] // $_FILES
38+
): string {
39+
$contentType = $serverData['CONTENT_TYPE'] ?? '';
40+
// The threshold is the maximum body size converted in bytes + 1
41+
$sizeThreshold = ($maxBodySize * 1024) + 1;
42+
43+
if (false !== strpos($contentType, 'multipart/')) {
44+
return $this->getMultipartRawBody($contentType, $sizeThreshold, $postData, $filesData);
45+
}
46+
47+
return $this->getRawInput($sizeThreshold, $stream);
48+
}
49+
50+
private function appendFileData(
51+
array $fileArray,
52+
?int $index,
53+
string $fileKey,
54+
string $boundary,
55+
int $threshold,
56+
int &$currentSize
57+
): string {
58+
$fileName = is_array($fileArray['name']) ? $fileArray['name'][$index] : $fileArray['name'];
59+
$fileTmpName = is_array($fileArray['tmp_name']) ? $fileArray['tmp_name'][$index] : $fileArray['tmp_name'];
60+
$fileType = is_array($fileArray['type']) ? $fileArray['type'][$index] : $fileArray['type'];
61+
62+
$headerPart = '--' . $boundary . "\r\n";
63+
$headerPart .= "Content-Disposition: form-data; name=\"$fileKey\"; filename=\"$fileName\"\r\n";
64+
$headerPart .= "Content-Type: $fileType\r\n\r\n";
65+
66+
$currentSize += strlen($headerPart);
67+
if ($currentSize >= $threshold) {
68+
return substr($headerPart, 0, $threshold - ($currentSize - strlen($headerPart)));
69+
}
70+
71+
$remainingSize = $threshold - $currentSize;
72+
$fileStream = fopen($fileTmpName, 'rb');
73+
$fileContent = $this->readStream($fileStream, $remainingSize);
74+
// Add 2 bytes for the \r\n at the end of the file content
75+
$currentSize += strlen($fileContent) + 2;
76+
77+
return $headerPart . $fileContent . "\r\n";
78+
}
79+
80+
private function buildFormData(string $boundary, string $key, string $value): string
81+
{
82+
return '--' . $boundary . "\r\n" .
83+
"Content-Disposition: form-data; name=\"$key\"\r\n\r\n" .
84+
"$value\r\n";
85+
}
86+
87+
/**
88+
* Extract the boundary from the Content-Type.
89+
*
90+
* Regex breakdown:
91+
* /boundary="?([^;"]+)"?/i
92+
*
93+
* - boundary= : Matches the literal string 'boundary=' which indicates the start of the boundary parameter.
94+
* - "? : Matches an optional double quote that may surround the boundary value.
95+
* - ([^;"]+) : Captures one or more characters that are not a semicolon (;) or a double quote (") into a group.
96+
* This ensures the boundary is extracted accurately, stopping at a semicolon if present,
97+
* and avoiding the inclusion of quotes in the captured value.
98+
* - "? : Matches an optional closing double quote (if the boundary is quoted).
99+
* - i : Case-insensitive flag to handle 'boundary=' in any case (e.g., 'Boundary=' or 'BOUNDARY=').
100+
*
101+
* @throws BouncerException
102+
*/
103+
private function extractBoundary(string $contentType): string
104+
{
105+
if (preg_match('/boundary="?([^;"]+)"?/i', $contentType, $matches)) {
106+
return trim($matches[1]);
107+
}
108+
throw new BouncerException("Failed to extract boundary from Content-Type: ($contentType)");
109+
}
110+
111+
/**
112+
* Return the raw body for multipart requests.
113+
* This method will read the raw body up to the specified threshold.
114+
* If the body is too large, it will return a truncated version of the body up to the threshold.
115+
*
116+
* @throws BouncerException
117+
*/
118+
private function getMultipartRawBody(
119+
string $contentType,
120+
int $threshold,
121+
array $postData,
122+
array $filesData
123+
): string {
124+
try {
125+
$boundary = $this->extractBoundary($contentType);
126+
// Instead of concatenating strings, we will use an array to store the parts
127+
// and then join them with implode at the end to avoid performance issues.
128+
$parts = [];
129+
$currentSize = 0;
130+
131+
foreach ($postData as $key => $value) {
132+
$formData = $this->buildFormData($boundary, $key, $value);
133+
$currentSize += strlen($formData);
134+
if ($currentSize >= $threshold) {
135+
return substr(implode('', $parts) . $formData, 0, $threshold);
136+
}
137+
138+
$parts[] = $formData;
139+
}
140+
141+
foreach ($filesData as $fileKey => $fileArray) {
142+
$fileNames = is_array($fileArray['name']) ? $fileArray['name'] : [$fileArray['name']];
143+
foreach ($fileNames as $index => $fileName) {
144+
$remainingSize = $threshold - $currentSize;
145+
$fileData =
146+
$this->appendFileData($fileArray, $index, $fileKey, $boundary, $remainingSize, $currentSize);
147+
if ($currentSize >= $threshold) {
148+
return substr(implode('', $parts) . $fileData, 0, $threshold);
149+
}
150+
$parts[] = $fileData;
151+
}
152+
}
153+
154+
$endBoundary = '--' . $boundary . "--\r\n";
155+
$currentSize += strlen($endBoundary);
156+
157+
if ($currentSize >= $threshold) {
158+
return substr(implode('', $parts) . $endBoundary, 0, $threshold);
159+
}
160+
161+
$parts[] = $endBoundary;
162+
163+
return implode('', $parts);
164+
} catch (\Throwable $e) {
165+
throw new BouncerException('Failed to read multipart raw body: ' . $e->getMessage());
166+
}
167+
}
168+
169+
private function getRawInput(int $threshold, $stream): string
170+
{
171+
return $this->readStream($stream, $threshold);
172+
}
173+
174+
/**
175+
* Read the stream up to the specified threshold.
176+
*
177+
* @param resource $stream The stream to read
178+
* @param int $threshold The maximum number of bytes to read
179+
*
180+
* @throws BouncerException
181+
*/
182+
private function readStream($stream, int $threshold): string
183+
{
184+
if (!is_resource($stream)) {
185+
throw new BouncerException('Stream is not a valid resource');
186+
}
187+
$buffer = '';
188+
$chunkSize = 8192;
189+
$bytesRead = 0;
190+
// We make sure there won't be infinite loop
191+
$maxLoops = (int) ceil($threshold / $chunkSize);
192+
$loopCount = -1;
193+
194+
try {
195+
while (!feof($stream) && $bytesRead < $threshold) {
196+
++$loopCount;
197+
if ($loopCount >= $maxLoops) {
198+
throw new BouncerException("Too many loops ($loopCount) while reading stream");
199+
}
200+
$remainingSize = $threshold - $bytesRead;
201+
$readLength = min($chunkSize, $remainingSize);
202+
203+
$data = fread($stream, $readLength);
204+
if (false === $data) {
205+
throw new BouncerException('Failed to read chunk from stream');
206+
}
207+
208+
$buffer .= $data;
209+
$bytesRead += strlen($data);
210+
211+
if ($bytesRead >= $threshold) {
212+
break;
213+
}
214+
}
215+
216+
return $buffer;
217+
} catch (\Throwable $e) {
218+
throw new BouncerException('Failed to read stream: ' . $e->getMessage());
219+
} finally {
220+
fclose($stream);
221+
}
222+
}
223+
}

0 commit comments

Comments
 (0)