diff --git a/src/Http/Middleware/TelescopeResponseMiddleware.php b/src/Http/Middleware/TelescopeResponseMiddleware.php index 48928f2..466d780 100644 --- a/src/Http/Middleware/TelescopeResponseMiddleware.php +++ b/src/Http/Middleware/TelescopeResponseMiddleware.php @@ -6,7 +6,12 @@ use Saloon\Http\Response; use Saloon\Laravel\Saloon; +use Illuminate\Support\Arr; +use Illuminate\Support\Str; use Saloon\Http\PendingRequest; +use Laravel\Telescope\Telescope; +use Laravel\Telescope\IncomingEntry; +use Psr\Http\Message\ResponseInterface; use Saloon\Contracts\ResponseMiddleware; class TelescopeResponseMiddleware implements ResponseMiddleware @@ -25,7 +30,7 @@ public function __invoke(Response $response): void $startTime = Saloon::$telescopeStartTimes[$requestId] ?? null; // Calculate duration - $duration = $startTime !== null ? (int)((microtime(true) - $startTime) * 1000) : null; + $duration = $startTime !== null ? (int) ((microtime(true) - $startTime) * 1000) : null; // Clean up start time unset(Saloon::$telescopeStartTimes[$requestId]); @@ -40,41 +45,140 @@ public function __invoke(Response $response): void */ protected function recordToTelescope(PendingRequest $pendingRequest, Response $response, ?int $duration): void { - if (! \Laravel\Telescope\Telescope::isRecording()) { + if (! Telescope::isRecording()) { return; } $psrRequest = $pendingRequest->createPsrRequest(); $psrResponse = $response->getPsrResponse(); - // Format request data - $requestData = [ - 'method' => $psrRequest->getMethod(), - 'url' => (string)$psrRequest->getUri(), - 'headers' => $psrRequest->getHeaders(), - 'body' => self::formatBody((string)$psrRequest->getBody(), $psrRequest->getHeaderLine('Content-Type')), - ]; - - // Format response data - $responseData = [ - 'status' => $psrResponse->getStatusCode(), - 'headers' => $psrResponse->getHeaders(), - 'body' => self::formatBody((string)$psrResponse->getBody(), $psrResponse->getHeaderLine('Content-Type')), - ]; + $rawRequestBody = (string) $psrRequest->getBody(); + $requestContentType = $psrRequest->getHeaderLine('Content-Type'); + $formattedRequestBody = self::formatBody($rawRequestBody, $requestContentType); + + $rawResponseBody = (string) $psrResponse->getBody(); + $responseContentType = $psrResponse->getHeaderLine('Content-Type'); + $formattedResponseBody = self::formatBody($rawResponseBody, $responseContentType); // Record to Telescope using IncomingEntry - $entry = \Laravel\Telescope\IncomingEntry::make([ - 'method' => $requestData['method'], - 'uri' => $requestData['url'], - 'headers' => $requestData['headers'], - 'payload' => $requestData['body'], - 'response_status' => $responseData['status'], - 'response_headers' => $responseData['headers'], - 'response' => $responseData['body'], + $entry = IncomingEntry::make([ + 'method' => $psrRequest->getMethod(), + 'uri' => (string) $psrRequest->getUri(), + 'headers' => $this->sanitizeHeaders($psrRequest->getHeaders()), + 'payload' => $this->sanitizePayload($formattedRequestBody), + 'response_status' => $psrResponse->getStatusCode(), + 'response_headers' => $this->sanitizeHeaders($psrResponse->getHeaders()), + 'response' => $this->sanitizeResponseBody($formattedResponseBody, $rawResponseBody, $psrResponse), 'duration' => $duration, ])->tags(['saloon']); - \Laravel\Telescope\Telescope::recordClientRequest($entry); + Telescope::recordClientRequest($entry); + } + + /** + * Normalize PSR-7 headers and apply the same redaction as ClientRequestWatcher::headers(). + * + * @param array> $psrHeaders + * @return array + */ + protected function sanitizeHeaders(array $psrHeaders): array + { + if ($psrHeaders === []) { + return []; + } + + $names = []; + $values = []; + + foreach ($psrHeaders as $name => $headerValues) { + $names[] = mb_strtolower($name); + $values[] = implode(', ', $headerValues); + } + + /** @var array $combined */ + $combined = array_combine($names, $values); + + return $this->hideParameters($combined, Telescope::$hiddenRequestHeaders); + } + + /** + * Apply Telescope::$hiddenRequestParameters like ClientRequestWatcher::payload(). + * + * @param array|string $formatted + * @return array|string + */ + protected function sanitizePayload(array|string $formatted): array|string + { + if (is_array($formatted)) { + return $this->hideParameters($formatted, Telescope::$hiddenRequestParameters); + } + + if ($formatted === '') { + return ''; + } + + $decoded = json_decode($formatted, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + return $this->hideParameters($decoded, Telescope::$hiddenRequestParameters); + } + + return $formatted; + } + + /** + * Match ClientRequestWatcher::response() for JSON, plain text, redirects, and size limits. + * + * @param array|string $formatted + * @return array|string + */ + protected function sanitizeResponseBody(array|string $formatted, string $rawBody, ResponseInterface $psrResponse): array|string + { + $contentType = mb_strtolower($psrResponse->getHeaderLine('Content-Type')); + + if (is_array($formatted)) { + return $this->contentWithinLimits($rawBody) + ? $this->hideParameters($formatted, Telescope::$hiddenResponseParameters) + : 'Purged By Telescope'; + } + + if (Str::startsWith($contentType, 'text/plain')) { + return $this->contentWithinLimits($formatted) ? $formatted : 'Purged By Telescope'; + } + + if ($psrResponse->getStatusCode() >= 300 && $psrResponse->getStatusCode() < 400) { + $location = $psrResponse->getHeaderLine('Location'); + + return $location !== '' ? 'Redirected to '.$location : 'Redirected'; + } + + if ($formatted === '') { + return 'Empty Response'; + } + + return 'HTML Response'; + } + + /** + * @param array $data + * @param array $hidden + * @return array + */ + protected function hideParameters(array $data, array $hidden): array + { + foreach ($hidden as $parameter) { + if (Arr::get($data, $parameter)) { + Arr::set($data, $parameter, '********'); + } + } + + return $data; + } + + protected function contentWithinLimits(string $content): bool + { + $limit = 64; + + return mb_strlen($content) / 1000 <= $limit; } /** diff --git a/tests/Feature/TelescopeMiddlewareTest.php b/tests/Feature/TelescopeMiddlewareTest.php index 9889df8..08f371c 100644 --- a/tests/Feature/TelescopeMiddlewareTest.php +++ b/tests/Feature/TelescopeMiddlewareTest.php @@ -4,17 +4,21 @@ use Saloon\Laravel\Saloon; use Saloon\Http\PendingRequest; +use Laravel\Telescope\EntryType; +use Laravel\Telescope\Telescope; use Saloon\Laravel\Tests\Fixtures\Requests\UserRequest; use Saloon\Laravel\Tests\Fixtures\Connectors\TestConnector; use Saloon\Laravel\Http\Middleware\TelescopeRequestMiddleware; use Saloon\Laravel\Http\Middleware\TelescopeResponseMiddleware; +use Saloon\Laravel\Tests\Fixtures\Requests\PostSensitiveFormRequest; +use Saloon\Laravel\Tests\Fixtures\Requests\PostSensitiveJsonRequest; test('telescope middleware handles sending event without errors when telescope is not available', function () { $connector = TestConnector::make(); - $request = new UserRequest(); + $request = new UserRequest; $pendingRequest = new PendingRequest($connector, $request); - $middleware = new TelescopeRequestMiddleware(); + $middleware = new TelescopeRequestMiddleware; // Should not throw any exceptions even when Telescope is not available expect(function () use ($middleware, $pendingRequest) { @@ -24,10 +28,10 @@ test('telescope middleware works with any sender', function () { $connector = TestConnector::make(); - $request = new UserRequest(); + $request = new UserRequest; $pendingRequest = new PendingRequest($connector, $request); - $middleware = new TelescopeRequestMiddleware(); + $middleware = new TelescopeRequestMiddleware; // Should handle gracefully for any sender type expect(function () use ($middleware, $pendingRequest) { @@ -37,13 +41,13 @@ test('telescope middleware tracks start time', function () { $connector = TestConnector::make(); - $request = new UserRequest(); + $request = new UserRequest; expect(Saloon::$telescopeStartTimes)->toHaveCount(0); $pendingRequest = new PendingRequest($connector, $request); - $middleware = new TelescopeRequestMiddleware(); + $middleware = new TelescopeRequestMiddleware; $middleware->__invoke($pendingRequest); expect(Saloon::$telescopeStartTimes)->toHaveCount(1); @@ -51,7 +55,7 @@ test('telescope middleware calculates duration', function () { $connector = TestConnector::make(); - $request = new UserRequest(); + $request = new UserRequest; $pendingRequest = new PendingRequest($connector, $request); // Create a mock response @@ -59,7 +63,7 @@ $psrResponse = new \GuzzleHttp\Psr7\Response(200, [], '{"name":"Test"}'); $response = \Saloon\Http\Response::fromPsrResponse($psrResponse, $pendingRequest, $psrRequest); - $middleware = new TelescopeRequestMiddleware(); + $middleware = new TelescopeRequestMiddleware; // Handle sending event to set start time $middleware->__invoke($pendingRequest); @@ -74,7 +78,7 @@ }); test('telescope middleware formats json body', function () { - $middleware = new TelescopeResponseMiddleware(); + $middleware = new TelescopeResponseMiddleware; $reflection = new ReflectionClass($middleware); $method = $reflection->getMethod('formatBody'); @@ -89,7 +93,7 @@ }); test('telescope middleware formats form body', function () { - $middleware = new TelescopeResponseMiddleware(); + $middleware = new TelescopeResponseMiddleware; $reflection = new ReflectionClass($middleware); $method = $reflection->getMethod('formatBody'); @@ -104,7 +108,7 @@ }); test('telescope middleware returns string for non-json non-form body', function () { - $middleware = new TelescopeResponseMiddleware(); + $middleware = new TelescopeResponseMiddleware; $reflection = new ReflectionClass($middleware); $method = $reflection->getMethod('formatBody'); @@ -117,7 +121,7 @@ }); test('telescope middleware returns string for non-json body when header says response is json', function () { - $middleware = new TelescopeResponseMiddleware(); + $middleware = new TelescopeResponseMiddleware; $reflection = new ReflectionClass($middleware); $method = $reflection->getMethod('formatBody'); @@ -128,3 +132,131 @@ expect($formatted)->toBeString(); expect($formatted)->toBe('123456'); }); + +/** + * @return array{hiddenRequestParameters: array, hiddenRequestHeaders: array, hiddenResponseParameters: array, shouldRecord: bool} + */ +function snapshotTelescopeRedactionSettings(): array +{ + return [ + 'hiddenRequestParameters' => Telescope::$hiddenRequestParameters, + 'hiddenRequestHeaders' => Telescope::$hiddenRequestHeaders, + 'hiddenResponseParameters' => Telescope::$hiddenResponseParameters, + 'shouldRecord' => Telescope::$shouldRecord, + ]; +} + +/** + * @param array{hiddenRequestParameters: array, hiddenRequestHeaders: array, hiddenResponseParameters: array, shouldRecord: bool} $snapshot + */ +function restoreTelescopeRedactionSettings(array $snapshot): void +{ + Telescope::$hiddenRequestParameters = $snapshot['hiddenRequestParameters']; + Telescope::$hiddenRequestHeaders = $snapshot['hiddenRequestHeaders']; + Telescope::$hiddenResponseParameters = $snapshot['hiddenResponseParameters']; + Telescope::$shouldRecord = $snapshot['shouldRecord']; + Telescope::flushEntries(); +} + +test('telescope response middleware redacts request payload and authorization header for recorded client requests', function () { + $snapshot = snapshotTelescopeRedactionSettings(); + + try { + Telescope::flushEntries(); + Telescope::$shouldRecord = true; + Telescope::$hiddenRequestParameters = ['password', 'client_secret']; + Telescope::$hiddenRequestHeaders = ['authorization']; + Telescope::$hiddenResponseParameters = []; + + $connector = TestConnector::make(); + $connector->headers()->add('Authorization', 'Bearer bearer-token-plaintext'); + $request = new PostSensitiveJsonRequest; + $pendingRequest = new PendingRequest($connector, $request); + + (new TelescopeRequestMiddleware)->__invoke($pendingRequest); + + $psrRequest = $pendingRequest->createPsrRequest(); + $psrResponse = new \GuzzleHttp\Psr7\Response(200, ['Content-Type' => 'application/json'], '{"ok":true}'); + $response = \Saloon\Http\Response::fromPsrResponse($psrResponse, $pendingRequest, $psrRequest); + + (new TelescopeResponseMiddleware)->__invoke($response); + + expect(Telescope::$entriesQueue)->not->toBeEmpty(); + + $entry = collect(Telescope::$entriesQueue)->last(fn ($e) => $e->type === EntryType::CLIENT_REQUEST); + + expect($entry)->not->toBeNull(); + expect($entry->content['payload']['password'])->toBe('********'); + expect($entry->content['payload']['client_secret'])->toBe('********'); + expect($entry->content['payload']['email'])->toBe('user@example.com'); + expect($entry->content['headers']['authorization'])->toBe('********'); + } finally { + restoreTelescopeRedactionSettings($snapshot); + } +}); + +test('telescope response middleware redacts JSON response fields using hidden response parameters', function () { + $snapshot = snapshotTelescopeRedactionSettings(); + + try { + Telescope::flushEntries(); + Telescope::$shouldRecord = true; + Telescope::$hiddenRequestParameters = []; + Telescope::$hiddenRequestHeaders = []; + Telescope::$hiddenResponseParameters = ['access_token']; + + $connector = TestConnector::make(); + $request = new PostSensitiveJsonRequest; + $pendingRequest = new PendingRequest($connector, $request); + + (new TelescopeRequestMiddleware)->__invoke($pendingRequest); + + $psrRequest = $pendingRequest->createPsrRequest(); + $responseBody = json_encode(['access_token' => 'secret-token-value', 'expires_in' => 3600]); + $psrResponse = new \GuzzleHttp\Psr7\Response(200, ['Content-Type' => 'application/json'], $responseBody); + $response = \Saloon\Http\Response::fromPsrResponse($psrResponse, $pendingRequest, $psrRequest); + + (new TelescopeResponseMiddleware)->__invoke($response); + + $entry = collect(Telescope::$entriesQueue)->last(fn ($e) => $e->type === EntryType::CLIENT_REQUEST); + + expect($entry)->not->toBeNull(); + expect($entry->content['response']['access_token'])->toBe('********'); + expect($entry->content['response']['expires_in'])->toBe(3600); + } finally { + restoreTelescopeRedactionSettings($snapshot); + } +}); + +test('telescope response middleware redacts application/x-www-form-urlencoded payload', function () { + $snapshot = snapshotTelescopeRedactionSettings(); + + try { + Telescope::flushEntries(); + Telescope::$shouldRecord = true; + Telescope::$hiddenRequestParameters = ['password']; + Telescope::$hiddenRequestHeaders = []; + Telescope::$hiddenResponseParameters = []; + + $connector = TestConnector::make(); + $request = new PostSensitiveFormRequest; + $pendingRequest = new PendingRequest($connector, $request); + + (new TelescopeRequestMiddleware)->__invoke($pendingRequest); + + $psrRequest = $pendingRequest->createPsrRequest(); + $psrResponse = new \GuzzleHttp\Psr7\Response(200, [], ''); + $response = \Saloon\Http\Response::fromPsrResponse($psrResponse, $pendingRequest, $psrRequest); + + (new TelescopeResponseMiddleware)->__invoke($response); + + $entry = collect(Telescope::$entriesQueue)->last(fn ($e) => $e->type === EntryType::CLIENT_REQUEST); + + expect($entry)->not->toBeNull(); + expect($entry->content['payload']['password'])->toBe('********'); + expect($entry->content['payload']['email'])->toBe('a@b.test'); + expect($entry->content['payload']['name'])->toBe('test'); + } finally { + restoreTelescopeRedactionSettings($snapshot); + } +}); diff --git a/tests/Fixtures/Requests/PostSensitiveFormRequest.php b/tests/Fixtures/Requests/PostSensitiveFormRequest.php new file mode 100644 index 0000000..cab46af --- /dev/null +++ b/tests/Fixtures/Requests/PostSensitiveFormRequest.php @@ -0,0 +1,37 @@ + + */ + protected function defaultBody(): array + { + return [ + 'email' => 'a@b.test', + 'password' => 'form-secret', + 'name' => 'test', + ]; + } +} diff --git a/tests/Fixtures/Requests/PostSensitiveJsonRequest.php b/tests/Fixtures/Requests/PostSensitiveJsonRequest.php new file mode 100644 index 0000000..1fcc204 --- /dev/null +++ b/tests/Fixtures/Requests/PostSensitiveJsonRequest.php @@ -0,0 +1,37 @@ + + */ + protected function defaultBody(): array + { + return [ + 'email' => 'user@example.com', + 'password' => 'plain-password', + 'client_secret' => 'plain-client-secret', + ]; + } +}