From 8518718037131aa92902aecaf7f865d6e4bcbc07 Mon Sep 17 00:00:00 2001 From: Craig Potter Date: Sat, 18 Jul 2026 17:40:27 +0100 Subject: [PATCH] Add request body redaction and size limits Request bodies were always stored in full with no redaction or size cap, unlike responses. Adds excluded_request_body (mirroring excluded_response_body: '*', connector or request classes -> REDACTED) and max_request_size (kilobytes, byte-measured, default 100; oversized bodies stored as a placeholder). Bodies are now always persisted as strings, with a JSON fallback for custom non-Stringable repositories. --- README.md | 14 ++++++ config/barstool.php | 16 ++++++ src/Barstool.php | 56 +++++++++++++++------ tests/BarstoolTest.php | 56 +++++++++++++++++++++ tests/Fixtures/Requests/JsonPostRequest.php | 35 +++++++++++++ 5 files changed, 163 insertions(+), 14 deletions(-) create mode 100644 tests/Fixtures/Requests/JsonPostRequest.php diff --git a/README.md b/README.md index fd2d8e1..d8b5443 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,18 @@ Response bodies for sensitive endpoints can be kept out of the database entirely ], ``` +### Excluding request bodies + +Request bodies work the same way via `excluded_request_body` — useful for login requests, payloads containing personal data, or anything else that shouldn't sit in your logs: + +```php +'excluded_request_body' => [ + LoginRequest::class, // exclude the body for a single request + // SensitiveConnector::class, // or a whole connector + // '*', // or every request +], +``` + ### Response body limits To keep the table lean, Barstool only stores response bodies that are: @@ -170,6 +182,8 @@ To keep the table lean, Barstool only stores response bodies that are: 'max_response_size' => 100, ``` +Request bodies have a matching limit, `max_request_size` (also kilobytes, default `100`) — oversized request bodies are stored as ``. + You may also spot a couple of other placeholder values in the `barstools` table: streamed request/response bodies are stored as `` (reading them would consume the stream before your application gets it), and multipart request bodies as ``. ### Database connection diff --git a/config/barstool.php b/config/barstool.php index 328b0c3..3f2d1df 100644 --- a/config/barstool.php +++ b/config/barstool.php @@ -24,6 +24,11 @@ */ 'max_response_size' => 100, + /* + * The maximum size of the request body that will be stored in kilobytes. + */ + 'max_request_size' => 100, + /* * Indicates if successful response bodies and headers should be kept. * If false, the request, status, duration and outcome are still recorded @@ -84,6 +89,17 @@ // SensitiveConnector::class // Exclude `token` header for this request ], + /* + * Any request body that should be excluded from the recording. + * This is useful for sensitive data that should not be stored. + * You may exclude entire connectors or requests by class name. + */ + 'excluded_request_body' => [ + // '*', // All bodies + // SensitiveConnector::class, + // SensitiveRequest::class, + ], + /* * Any response headers that should be excluded from the recording. * Will replace the header value with `REDACTED`. diff --git a/src/Barstool.php b/src/Barstool.php index 5fe956d..3ee9d51 100755 --- a/src/Barstool.php +++ b/src/Barstool.php @@ -10,7 +10,6 @@ use Psr\Http\Message\UriInterface; use Illuminate\Support\Facades\Context; use Saloon\Barstool\Enums\RecordingType; -use Saloon\Contracts\Body\BodyRepository; use Saloon\Barstool\Jobs\RecordBarstoolJob; use Saloon\Repositories\Body\StreamBodyRepository; use Saloon\Exceptions\Request\FatalRequestException; @@ -131,28 +130,20 @@ public static function record(PendingRequest|Response|FatalRequestException $dat * method: string, * url: string, * request_headers: array|null, - * request_body: BodyRepository|string|null, + * request_body: string|null, * successful: false, * context?: array * } */ private static function getRequestData(PendingRequest $request): array { - $body = $request->body(); - - $body = match (true) { - $body instanceof StreamBodyRepository => '', - $body instanceof MultipartBodyRepository => '', - default => $body, - }; - $data = [ 'connector_class' => get_class($request->getConnector()), 'request_class' => get_class($request->getRequest()), 'method' => $request->getMethod()->value, 'url' => $request->getUrl(), 'request_headers' => self::getRequestHeaders($request), - 'request_body' => $body, + 'request_body' => self::getRequestBody($request), 'successful' => false, ]; @@ -350,6 +341,41 @@ private static function supportedContentTypes(): array ]; } + public static function getRequestBody(PendingRequest $request): ?string + { + $body = $request->body(); + + if ($body === null) { + return null; + } + + if ($body instanceof StreamBodyRepository) { + return ''; + } + + if ($body instanceof MultipartBodyRepository) { + return ''; + } + + $excludedBodies = config('barstool.excluded_request_body', []); + + if (in_array('*', $excludedBodies) + || in_array(get_class($request->getConnector()), $excludedBodies) + || in_array(get_class($request->getRequest()), $excludedBodies)) { + return 'REDACTED'; + } + + // Saloon's standard repositories are all Stringable; fall back to + // encoding the raw contents for custom repositories that are not. + $body = $body instanceof \Stringable + ? (string) $body + : (string) json_encode($body->all()); + + return self::checkContentSize($body, (int) config('barstool.max_request_size', 100)) + ? $body + : ''; + } + /** * @return array|null */ @@ -425,19 +451,21 @@ public static function getResponseBody(Response $response): string $body = $response->body(); - return self::checkContentSize($body) ? $body : ''; + return self::checkContentSize($body, (int) config('barstool.max_response_size', 100)) + ? $body + : ''; } /** * Check if the content is within limits */ - private static function checkContentSize(mixed $body): bool + private static function checkContentSize(mixed $body, int $maxKilobytes): bool { try { $body = (string) $body; // strlen, not mb_strlen: the limit is about storage size, so count bytes - return intdiv(strlen($body), 1000) <= config('barstool.max_response_size', 100); + return intdiv(strlen($body), 1000) <= $maxKilobytes; } catch (\Throwable) { return false; } diff --git a/tests/BarstoolTest.php b/tests/BarstoolTest.php index 97152e8..0542905 100644 --- a/tests/BarstoolTest.php +++ b/tests/BarstoolTest.php @@ -26,6 +26,7 @@ use Saloon\Exceptions\Request\FatalRequestException; use Saloon\Barstool\Tests\Fixtures\Requests\PostRequest; use Saloon\Barstool\Tests\Fixtures\Requests\GetFileRequest; +use Saloon\Barstool\Tests\Fixtures\Requests\JsonPostRequest; use Saloon\Barstool\Tests\Fixtures\Requests\SoloUserRequest; use Saloon\Barstool\Tests\Fixtures\Connectors\RandomConnector; use Saloon\Barstool\Tests\Fixtures\Requests\RequestWithConnector; @@ -1260,3 +1261,58 @@ expect(BarstoolRecorder::calculateDuration($pendingRequest))->toBe(1); }); + +it('records json request bodies as strings', function () { + config()->set('barstool.enabled', true); + + MockClient::global([ + JsonPostRequest::class => MockResponse::make(body: ['data' => 'ok'], status: 200), + ]); + + (new RandomConnector)->send(new JsonPostRequest(['name' => 'Doc Holliday'])); + + expect(Barstool::sole()->request_body)->toBe(json_encode(['name' => 'Doc Holliday'])); +}); + +it('can exclude request bodies for all requests, entire connectors or entire requests', function ($excluded) { + config()->set('barstool.enabled', true); + config()->set('barstool.excluded_request_body', [$excluded]); + + MockClient::global([ + JsonPostRequest::class => MockResponse::make(body: ['data' => 'ok'], status: 200), + ]); + + (new RandomConnector)->send(new JsonPostRequest(['password' => 'super-secret'])); + + expect(Barstool::sole()->request_body)->toBe('REDACTED'); +})->with([ + '*', + RandomConnector::class, + JsonPostRequest::class, +]); + +it('limits stored request bodies to max_request_size in bytes', function () { + config()->set('barstool.enabled', true); + config()->set('barstool.max_request_size', 1); + + MockClient::global([ + JsonPostRequest::class => MockResponse::make(body: ['data' => 'ok'], status: 200), + ]); + + // 700 three-byte characters: 700 chars but 2100 bytes - over a 1KB byte limit + (new RandomConnector)->send(new JsonPostRequest(['blob' => str_repeat('€', 700)])); + + expect(Barstool::sole()->request_body)->toBe(''); +}); + +it('stores a null request body when the request has none', function () { + config()->set('barstool.enabled', true); + + MockClient::global([ + SoloUserRequest::class => MockResponse::make(body: ['data' => 'ok'], status: 200), + ]); + + (new SoloUserRequest)->send(); + + expect(Barstool::sole()->request_body)->toBeNull(); +}); diff --git a/tests/Fixtures/Requests/JsonPostRequest.php b/tests/Fixtures/Requests/JsonPostRequest.php new file mode 100644 index 0000000..992411f --- /dev/null +++ b/tests/Fixtures/Requests/JsonPostRequest.php @@ -0,0 +1,35 @@ + $payload + */ + public function __construct(protected array $payload = ['name' => 'Doc Holliday']) {} + + /** + * @return array + */ + protected function defaultBody(): array + { + return $this->payload; + } + + public function resolveEndpoint(): string + { + return 'user'; + } +}