Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/redaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
26 changes: 22 additions & 4 deletions src/Http/Middleware/LogApiRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]');
Expand All @@ -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');
Comment thread
jfrosorio marked this conversation as resolved.
Resolved
}
}
52 changes: 52 additions & 0 deletions tests/Feature/LogApiRequestMiddlewareTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
});
}

Expand Down Expand Up @@ -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('<html', $log->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']);
Expand Down
Loading