diff --git a/src/DataCollection/HttpBodyCollector.php b/src/DataCollection/HttpBodyCollector.php index f4048070b..c50b6792b 100644 --- a/src/DataCollection/HttpBodyCollector.php +++ b/src/DataCollection/HttpBodyCollector.php @@ -5,6 +5,7 @@ namespace Sentry\DataCollection; use GuzzleHttp\Psr7\Query; +use Psr\Http\Message\MessageInterface; use Psr\Http\Message\ServerRequestInterface; use Sentry\Exception\JsonException; use Sentry\Util\JSON; @@ -37,6 +38,14 @@ public static function collectServerRequest(DataCollectionPolicy $policy, Server return self::collectSource($policy, HttpMessageType::incomingRequest(), new ServerRequestBodySource($request)); } + /** + * @return array|string|null + */ + public static function collectPsr7Message(DataCollectionPolicy $policy, HttpMessageType $messageType, MessageInterface $message) + { + return self::collectSource($policy, $messageType, new Psr7MessageBodySource($message)); + } + /** * @return array|string|null */ diff --git a/src/DataCollection/HttpCookieCollector.php b/src/DataCollection/HttpCookieCollector.php index 7b547bfb1..618ff3167 100644 --- a/src/DataCollection/HttpCookieCollector.php +++ b/src/DataCollection/HttpCookieCollector.php @@ -4,6 +4,9 @@ namespace Sentry\DataCollection; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; + final class HttpCookieCollector { private function __construct() @@ -29,6 +32,32 @@ public static function collect(DataCollectionPolicy $policy, HttpMessageType $ty return (new KeyValueDataFilter($dataCollection->getCookies()))->filterKeyValueData($cookies); } + /** + * @return array|string|null `null` if cookies are not collected, `[Filtered]` if they + * could not be parsed + */ + public static function collectPsr7Request(DataCollectionPolicy $policy, HttpMessageType $type, RequestInterface $request) + { + if (!self::shouldCollect($policy, $type)) { + return null; + } + + return self::collectGroupedPairs($policy, $type, HttpCookieParser::parseCookieHeaders($request->getHeader('Cookie'))); + } + + /** + * @return array|string|null `null` if cookies are not collected, `[Filtered]` if they + * could not be parsed + */ + public static function collectPsr7Response(DataCollectionPolicy $policy, HttpMessageType $type, ResponseInterface $response) + { + if (!self::shouldCollect($policy, $type)) { + return null; + } + + return self::collectGroupedPairs($policy, $type, HttpCookieParser::parseSetCookieHeaders($response->getHeader('Set-Cookie'))); + } + /** * @param array $cookies * @@ -78,6 +107,16 @@ public static function collectGroupedPairs(DataCollectionPolicy $policy, HttpMes return $grouped; } + private static function shouldCollect(DataCollectionPolicy $policy, HttpMessageType $type): bool + { + $dataCollection = $policy->getDataCollection(); + if ($dataCollection === null) { + return self::shouldCollectLegacyCookies($policy, $type); + } + + return !$dataCollection->getCookies()->isOff(); + } + /** * The legacy options only collected the cookies of incoming requests, and only with `send_default_pii`. */ diff --git a/src/DataCollection/Psr7MessageBodySource.php b/src/DataCollection/Psr7MessageBodySource.php new file mode 100644 index 000000000..176ddf0b5 --- /dev/null +++ b/src/DataCollection/Psr7MessageBodySource.php @@ -0,0 +1,46 @@ +message = $message; + } + + public function getKnownLength(): ?int + { + $length = $this->message->getHeaderLine('Content-Length'); + + return is_numeric($length) ? (int) $length : null; + } + + public function getContentType(): string + { + return $this->message->getHeaderLine('Content-Type'); + } + + public function read(int $limit): ?string + { + $stream = $this->message->getBody(); + $size = $stream->getSize(); + if ($limit !== -1 && $size !== null && $size > $limit) { + return null; + } + + return Psr7BodyReader::read($stream, $limit); + } +} diff --git a/tests/DataCollection/HttpBodyCollectorTest.php b/tests/DataCollection/HttpBodyCollectorTest.php index b70c8a8cc..f85e64cf8 100644 --- a/tests/DataCollection/HttpBodyCollectorTest.php +++ b/tests/DataCollection/HttpBodyCollectorTest.php @@ -5,14 +5,18 @@ namespace Sentry\Tests\DataCollection; use GuzzleHttp\Psr7\NoSeekStream; +use GuzzleHttp\Psr7\Response; use GuzzleHttp\Psr7\ServerRequest; use PHPUnit\Framework\TestCase; +use Psr\Http\Message\MessageInterface; use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\StreamInterface; use Psr\Http\Message\UploadedFileInterface; use Sentry\DataCollection\DataCollectionPolicy; use Sentry\DataCollection\HttpBodyCollector; use Sentry\DataCollection\HttpBodySourceInterface; use Sentry\DataCollection\HttpMessageType; +use Sentry\DataCollection\Psr7MessageBodySource; use Sentry\Event; use Sentry\Options; use Sentry\Serializer\PayloadSerializer; @@ -505,6 +509,79 @@ public function disabledServerRequestCollectionProvider(): \Generator yield 'configured bodies disabled' => [['data_collection' => ['http_bodies' => []]]]; } + public function testPsr7BodyIsCollectedWithoutChangingTheStreamPosition(): void + { + $response = new Response( + 200, + ['Content-Type' => 'application/json'], + '{"name":"Alice","token":"secret"}' + ); + $response->getBody()->seek(4); + + $this->assertSame(['name' => 'Alice', 'token' => '[Filtered]'], HttpBodyCollector::collectPsr7Message( + $this->options(), + HttpMessageType::incomingResponse(), + $response + )); + $this->assertSame(4, $response->getBody()->tell()); + } + + /** + * @dataProvider disabledSourceProvider + * + * @param array|null $options + */ + public function testDisabledPsr7MessagesAreNotAccessed(?array $options, HttpMessageType $messageType): void + { + $policy = DataCollectionPolicy::fromOptions($options === null ? null : new Options($options)); + $message = $this->createMock(MessageInterface::class); + $message->expects($this->never())->method('getHeaderLine'); + $message->expects($this->never())->method('getBody'); + + $this->assertNull(HttpBodyCollector::collectPsr7Message($policy, $messageType, $message)); + } + + public function testDeclaredOversizedPsr7MessagesAreNotRead(): void + { + $message = $this->createMock(MessageInterface::class); + $message->expects($this->once())->method('getHeaderLine')->with('Content-Length')->willReturn('100001'); + $message->expects($this->never())->method('getBody'); + + $this->assertNull(HttpBodyCollector::collectSource($this->options(), HttpMessageType::incomingResponse(), new Psr7MessageBodySource($message))); + } + + public function testOversizedPsr7StreamsAreNotRead(): void + { + $stream = $this->createMock(StreamInterface::class); + $stream->method('isReadable')->willReturn(true); + $stream->method('isSeekable')->willReturn(true); + $stream->expects($this->once())->method('getSize')->willReturn(100001); + $stream->expects($this->never())->method('read'); + $stream->expects($this->never())->method('rewind'); + $message = $this->createMock(MessageInterface::class); + $message->expects($this->once())->method('getHeaderLine')->with('Content-Length')->willReturn('1'); + $message->expects($this->once())->method('getBody')->willReturn($stream); + + $this->assertNull(HttpBodyCollector::collectSource($this->options(), HttpMessageType::incomingResponse(), new Psr7MessageBodySource($message))); + } + + public function testPsr7StreamsWithUnknownSizeUseTheConfiguredReadLimit(): void + { + $stream = $this->createMock(StreamInterface::class); + $stream->method('isReadable')->willReturn(true); + $stream->method('isSeekable')->willReturn(true); + $stream->method('getSize')->willReturn(null); + $stream->method('tell')->willReturn(4); + $stream->method('eof')->willReturn(false); + $stream->expects($this->once())->method('read')->with(1001)->willReturn(str_repeat('a', 1001)); + $stream->expects($this->once())->method('seek')->with(4); + $message = $this->createMock(MessageInterface::class); + $message->expects($this->once())->method('getHeaderLine')->with('Content-Length')->willReturn(''); + $message->expects($this->once())->method('getBody')->willReturn($stream); + + $this->assertNull(HttpBodyCollector::collectSource($this->options(['max_request_body_size' => 'small']), HttpMessageType::outgoingRequest(), new Psr7MessageBodySource($message))); + } + /** * @param array $options */ diff --git a/tests/DataCollection/HttpCookieCollectorTest.php b/tests/DataCollection/HttpCookieCollectorTest.php index 71bf242db..b399e74c9 100644 --- a/tests/DataCollection/HttpCookieCollectorTest.php +++ b/tests/DataCollection/HttpCookieCollectorTest.php @@ -4,7 +4,11 @@ namespace Sentry\Tests\DataCollection; +use GuzzleHttp\Psr7\Request; +use GuzzleHttp\Psr7\Response; use PHPUnit\Framework\TestCase; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; use Sentry\DataCollection\DataCollectionPolicy; use Sentry\DataCollection\HttpCookieCollector; use Sentry\DataCollection\HttpMessageType; @@ -27,6 +31,69 @@ public function testRequestCookiesAreFiltered(): void ])); } + public function testPsr7RequestCookiesAreParsedAndFiltered(): void + { + $request = new Request('GET', '/', ['Cookie' => 'theme=dark; session_id=secret; theme=light']); + + $this->assertSame([ + 'theme' => 'light', + 'session_id' => '[Filtered]', + ], HttpCookieCollector::collectPsr7Request($this->policy(), HttpMessageType::outgoingRequest(), $request)); + } + + public function testPsr7ResponseCookiesAreParsedAndGrouped(): void + { + $response = new Response(200, ['Set-Cookie' => [ + 'theme=dark; Path=/', + 'theme=light; Path=/settings', + 'session_id=secret; HttpOnly', + ]]); + + $this->assertSame([ + 'theme' => ['dark', 'light'], + 'session_id' => '[Filtered]', + ], HttpCookieCollector::collectPsr7Response($this->policy(), HttpMessageType::incomingResponse(), $response)); + } + + public function testDisabledPsr7RequestCookiesAreNotRead(): void + { + $request = $this->createMock(RequestInterface::class); + $request->expects($this->never())->method('getHeader'); + + $this->assertNull(HttpCookieCollector::collectPsr7Request($this->policy(['cookies' => ['mode' => 'off']]), HttpMessageType::outgoingRequest(), $request)); + } + + public function testDisabledPsr7ResponseCookiesAreNotRead(): void + { + $response = $this->createMock(ResponseInterface::class); + $response->expects($this->never())->method('getHeader'); + + $this->assertNull(HttpCookieCollector::collectPsr7Response($this->policy(['cookies' => ['mode' => 'off']]), HttpMessageType::incomingResponse(), $response)); + } + + public function testLegacyModeWithPiiCollectsIncomingPsr7RequestCookiesUnfiltered(): void + { + $policy = DataCollectionPolicy::fromOptions(new Options(['send_default_pii' => true])); + $request = new Request('GET', '/', ['Cookie' => 'theme=dark; session_id=secret']); + + $this->assertSame( + ['theme' => 'dark', 'session_id' => 'secret'], + HttpCookieCollector::collectPsr7Request($policy, HttpMessageType::incomingRequest(), $request) + ); + } + + public function testLegacyModeDoesNotReadOutgoingPsr7Cookies(): void + { + $policy = DataCollectionPolicy::fromOptions(new Options(['send_default_pii' => true])); + $request = $this->createMock(RequestInterface::class); + $request->expects($this->never())->method('getHeader'); + $response = $this->createMock(ResponseInterface::class); + $response->expects($this->never())->method('getHeader'); + + $this->assertNull(HttpCookieCollector::collectPsr7Request($policy, HttpMessageType::outgoingRequest(), $request)); + $this->assertNull(HttpCookieCollector::collectPsr7Response($policy, HttpMessageType::incomingResponse(), $response)); + } + public function testDisabledCollectionDoesNotCollectCookies(): void { $policy = $this->policy(['cookies' => ['mode' => 'off']]);