From 7508a56cf71119ec239a4299bf29d4b6b7b5da91 Mon Sep 17 00:00:00 2001 From: jfrosorio Date: Sat, 10 Oct 2026 15:48:37 +0100 Subject: [PATCH 1/2] fix(middleware): log responses that are not JSON --- src/Http/Middleware/LogApiRequest.php | 26 ++++++++-- tests/Feature/LogApiRequestMiddlewareTest.php | 52 +++++++++++++++++++ 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/Http/Middleware/LogApiRequest.php b/src/Http/Middleware/LogApiRequest.php index 98154ca..fd5e33d 100644 --- a/src/Http/Middleware/LogApiRequest.php +++ b/src/Http/Middleware/LogApiRequest.php @@ -35,9 +35,6 @@ public function terminate(Request $request, Response $response): void ?? $request->server('REQUEST_TIME_FLOAT') ?? microtime(true); - $content = $response->getContent(); - $responseData = is_string($content) ? json_decode($content, true) : null; - $keys = config('api-logs.redact.keys', []); $headers = config('api-logs.redact.headers', []); $replacement = config('api-logs.redact.replacement', '[REDACTED]'); @@ -51,7 +48,28 @@ public function terminate(Request $request, Response $response): void 'ip' => $request->getClientIp(), 'request_data' => Redactor::redact($request->all(), $keys, $replacement), 'request_headers' => Redactor::redactHeaders($request->headers->all(), $headers, $replacement), - 'response_data' => is_array($responseData) ? Redactor::redact($responseData, $keys, $replacement) : $responseData, + 'response_data' => $this->responseData($response, $keys, $replacement), ]); } + + /** + * Get the response body to store: the decoded JSON value when the body + * is JSON, or the raw body as a UTF-8 string otherwise. + */ + private function responseData(Response $response, array $keys, string $replacement): mixed + { + $content = $response->getContent(); + + if (! is_string($content)) { + return ''; + } + + $decoded = json_decode($content, true); + + if (is_array($decoded)) { + return Redactor::redact($decoded, $keys, $replacement); + } + + return $decoded ?? mb_scrub($content, 'UTF-8'); + } } diff --git a/tests/Feature/LogApiRequestMiddlewareTest.php b/tests/Feature/LogApiRequestMiddlewareTest.php index d3c9a88..7050e2d 100644 --- a/tests/Feature/LogApiRequestMiddlewareTest.php +++ b/tests/Feature/LogApiRequestMiddlewareTest.php @@ -18,6 +18,10 @@ protected function defineRoutes($router): void 'user' => ['email' => 'user@example.com'], 'access_token' => 'issued-token', ])); + $router->get('/api/forbidden', fn () => abort(403)); + $router->delete('/api/items', fn () => response()->noContent()); + $router->get('/api/export', fn () => response()->stream(fn () => print 'a,b,c')); + $router->get('/api/binary', fn () => response("abc\xff")); }); } @@ -136,6 +140,54 @@ public function test_request_is_not_logged_when_configured_guard_is_unauthentica $this->assertSame(0, ApiLog::count()); } + public function test_html_response_is_logged_with_its_raw_body(): void + { + $this->actingAs($this->createUser()) + ->get('/api/forbidden', ['Accept' => '*/*']) + ->assertForbidden(); + + $log = ApiLog::first(); + + $this->assertIsString($log->response_data); + $this->assertStringContainsString('response_data); + } + + public function test_empty_response_is_logged_with_an_empty_body(): void + { + $this->actingAs($this->createUser()) + ->delete('/api/items') + ->assertNoContent(); + + $this->assertSame('', ApiLog::first()->response_data); + } + + public function test_streamed_response_is_logged_with_an_empty_body(): void + { + $this->actingAs($this->createUser()) + ->get('/api/export') + ->assertOk(); + + $this->assertSame('', ApiLog::first()->response_data); + } + + public function test_response_that_is_not_valid_utf8_is_logged_with_its_body_scrubbed(): void + { + $this->actingAs($this->createUser()) + ->get('/api/binary') + ->assertOk(); + + $this->assertSame('abc?', ApiLog::first()->response_data); + } + + private function createUser(): User + { + return User::create([ + 'name' => 'Test User', + 'email' => 'user@example.com', + 'password' => 'secret', + ]); + } + private function defineApiGuard(): void { config()->set('auth.guards.api', ['driver' => 'session', 'provider' => 'users']); From ba64a48fb00123e9169b4c1d90edc9e431d5e3df Mon Sep 17 00:00:00 2001 From: jfrosorio Date: Sat, 10 Oct 2026 15:48:37 +0100 Subject: [PATCH 2/2] docs(redaction): note that only JSON responses are redacted --- docs/redaction.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/redaction.md b/docs/redaction.md index d08c777..6943905 100644 --- a/docs/redaction.md +++ b/docs/redaction.md @@ -22,3 +22,6 @@ file](configuration.md) and adjust the `redact` options in `config/api-logs.php` `keys` are matched case-insensitively and recursively against the request payload, query string and response data; `headers` are matched against request header names. + +Key redaction applies to JSON responses only. A response that is not JSON, such as an +HTML error page, is stored as its raw body, unredacted.