diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 121eab327..27f87db38 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -181,7 +181,7 @@ parameters: path: src/Options.php - - message: "#^Method Sentry\\\\Options\\:\\:getMaxRequestBodySize\\(\\) should return string but returns mixed\\.$#" + message: "#^Method Sentry\\\\Options\\:\\:getMaxRequestBodySize\\(\\) should return 'always'\\|'medium'\\|'never'\\|'none'\\|'small' but returns mixed\\.$#" count: 1 path: src/Options.php diff --git a/src/DataCollection/KeyValueDataFilter.php b/src/DataCollection/KeyValueDataFilter.php index 0af97c5af..e5d221837 100644 --- a/src/DataCollection/KeyValueDataFilter.php +++ b/src/DataCollection/KeyValueDataFilter.php @@ -4,6 +4,8 @@ namespace Sentry\DataCollection; +use Sentry\Util\Arr; + /** * @internal * @@ -11,6 +13,13 @@ */ final class KeyValueDataFilter { + public const FILTERED_VALUE = '[Filtered]'; + + private const DEFAULT_BODY_FILTER_BEHAVIOR = [ + 'mode' => 'denyList', + 'terms' => [], + ]; + private const SENSITIVE_DATA_DENYLIST = [ 'auth', 'token', @@ -68,7 +77,7 @@ public static function filterHeaders(array $headers, array $behavior): ?array if (\in_array(strtolower($name), self::SENSITIVE_HEADERS, true) || self::shouldFilterValue($name, $behavior)) { foreach ($values as $headerLine => $headerValue) { - $values[$headerLine] = '[Filtered]'; + $values[$headerLine] = self::FILTERED_VALUE; } } @@ -98,7 +107,7 @@ public static function filterKeyValueData(array $data, array $behavior): ?array $key = (string) $key; if (self::shouldFilterValue($key, $behavior)) { - $filtered[$key] = '[Filtered]'; + $filtered[$key] = self::FILTERED_VALUE; } elseif (\is_array($value)) { $filtered[$key] = self::filterKeyValueData($value, $behavior); } else { @@ -109,6 +118,31 @@ public static function filterKeyValueData(array $data, array $behavior): ?array return $filtered; } + /** + * Filters structured HTTP body data while replacing unkeyed top-level values. + * + * @param array $data + * + * @return array + */ + public static function filterHttpBodyData(array $data): array + { + if (!Arr::isList($data)) { + return self::filterKeyValueData($data, self::DEFAULT_BODY_FILTER_BEHAVIOR) ?? []; + } + + $filtered = []; + + /** @mago-ignore analysis:mixed-assignment */ + foreach ($data as $value) { + $filtered[] = \is_array($value) + ? self::filterHttpBodyData($value) + : self::FILTERED_VALUE; + } + + return $filtered; + } + /** * @phpstan-param KeyValueCollectionBehavior $behavior */ @@ -126,7 +160,7 @@ public static function filterQueryString(string $queryString, array $behavior): $key = urldecode($encodedKey); if ($separatorPosition !== false && self::shouldFilterValue($key, $behavior)) { - $parts[$index] = $encodedKey . '=[Filtered]'; + $parts[$index] = $encodedKey . '=' . self::FILTERED_VALUE; } } diff --git a/src/DataCollection/RequestDataCollector.php b/src/DataCollection/RequestDataCollector.php index 1a49e24a4..45cd82d67 100644 --- a/src/DataCollection/RequestDataCollector.php +++ b/src/DataCollection/RequestDataCollector.php @@ -142,13 +142,10 @@ public function collectRequestBody($body) } if (!\is_array($body)) { - return '[Filtered]'; + return KeyValueDataFilter::FILTERED_VALUE; } - return KeyValueDataFilter::filterKeyValueData($body, [ - 'mode' => 'denyList', - 'terms' => [], - ]); + return KeyValueDataFilter::filterHttpBodyData($body); } /** @@ -165,7 +162,7 @@ private function sanitizeLegacyHeaders(array $headers): array if (\in_array(strtolower($name), $this->piiSanitizeHeaders, true)) { foreach ($values as $headerLine => $headerValue) { - $values[$headerLine] = '[Filtered]'; + $values[$headerLine] = KeyValueDataFilter::FILTERED_VALUE; } } diff --git a/src/Options.php b/src/Options.php index d420894a4..07f0025eb 100644 --- a/src/Options.php +++ b/src/Options.php @@ -1151,6 +1151,8 @@ public function setCaptureSilencedErrors(bool $shouldCapture): self /** * Gets the limit up to which integrations should capture the HTTP request * body. + * + * @return 'none'|'never'|'small'|'medium'|'always' */ public function getMaxRequestBodySize(): string { diff --git a/src/Tracing/GuzzleTracingMiddleware.php b/src/Tracing/GuzzleTracingMiddleware.php index 480502c9c..a883dee22 100644 --- a/src/Tracing/GuzzleTracingMiddleware.php +++ b/src/Tracing/GuzzleTracingMiddleware.php @@ -5,13 +5,19 @@ namespace Sentry\Tracing; use GuzzleHttp\Exception\RequestException as GuzzleRequestException; +use GuzzleHttp\Psr7\Query; use GuzzleHttp\Psr7\Uri; +use GuzzleHttp\Psr7\Utils; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\StreamInterface; use Sentry\Breadcrumb; -use Sentry\ClientInterface; +use Sentry\DataCollection\DataCollectionOptions; +use Sentry\DataCollection\KeyValueDataFilter; +use Sentry\Options; use Sentry\SentrySdk; use Sentry\State\HubInterface; +use Sentry\Util\JSON; use function Sentry\getBaggage; use function Sentry\getTraceparent; @@ -21,6 +27,17 @@ */ final class GuzzleTracingMiddleware { + // Avoid reading arbitrarily large or unknown-sized streams into memory. + private const HTTP_BODY_MAX_CONTENT_LENGTH = 10 ** 5; + + private const MAX_REQUEST_BODY_SIZE_TO_LENGTH = [ + 'none' => 0, + 'never' => 0, + 'small' => 10 ** 3, + 'medium' => 10 ** 4, + 'always' => self::HTTP_BODY_MAX_CONTENT_LENGTH, + ]; + public static function trace(?HubInterface $hub = null): \Closure { return static function (callable $handler) use ($hub): \Closure { @@ -28,32 +45,59 @@ public static function trace(?HubInterface $hub = null): \Closure $hub = $hub ?? SentrySdk::getCurrentHub(); $client = $hub->getClient(); $parentSpan = $hub->getSpan(); + $requestUri = $request->getUri(); + $requestBody = $request->getBody(); $partialUri = Uri::fromParts([ - 'scheme' => $request->getUri()->getScheme(), - 'host' => $request->getUri()->getHost(), - 'port' => $request->getUri()->getPort(), - 'path' => $request->getUri()->getPath(), + 'scheme' => $requestUri->getScheme(), + 'host' => $requestUri->getHost(), + 'port' => $requestUri->getPort(), + 'path' => $requestUri->getPath(), ]); + $sdkOptions = $client !== null ? $client->getOptions() : null; + $dataCollection = $sdkOptions !== null ? $sdkOptions->getDataCollection() : null; $spanAndBreadcrumbData = [ 'http.request.method' => $request->getMethod(), - 'http.request.body.size' => $request->getBody()->getSize(), + 'http.request.body.size' => $requestBody->getSize(), ]; - if ($request->getUri()->getQuery() !== '') { - $spanAndBreadcrumbData['http.query'] = $request->getUri()->getQuery(); + $queryString = self::collectQueryString($dataCollection, $requestUri->getQuery()); + if ($queryString !== null) { + $spanAndBreadcrumbData['http.query'] = $queryString; } - if ($request->getUri()->getFragment() !== '') { - $spanAndBreadcrumbData['http.fragment'] = $request->getUri()->getFragment(); + if ($requestUri->getFragment() !== '') { + $spanAndBreadcrumbData['http.fragment'] = $requestUri->getFragment(); + } + + $collectedUri = $partialUri; + if ($dataCollection !== null) { + $collectedUri = $collectedUri + ->withQuery($queryString ?? '') + ->withFragment($requestUri->getFragment()); + $spanAndBreadcrumbData['url.full'] = (string) $collectedUri; } $childSpan = null; + $spanData = $spanAndBreadcrumbData; if ($parentSpan !== null && $parentSpan->getSampled()) { + if ($dataCollection !== null && $sdkOptions !== null) { + // Headers and bodies can be sizeable, so keep them on the recorded span instead of duplicating them on its breadcrumb. + $spanData = array_merge( + $spanData, + self::collectRequestSpanData( + $dataCollection, + $sdkOptions->getMaxRequestBodySize(), + $request, + $requestBody + ) + ); + } + $spanContext = new SpanContext(); $spanContext->setOp('http.client'); - $spanContext->setData($spanAndBreadcrumbData); + $spanContext->setData($spanData); $spanContext->setOrigin('auto.http.guzzle'); $spanContext->setDescription($request->getMethod() . ' ' . $partialUri); @@ -62,7 +106,7 @@ public static function trace(?HubInterface $hub = null): \Closure $hub->setSpan($childSpan); } - if (self::shouldAttachTracingHeaders($client, $request)) { + if (self::shouldAttachTracingHeaders($sdkOptions, $request)) { $traceParent = getTraceparent(); if ($traceParent !== '') { $request = $request->withHeader('sentry-trace', $traceParent); @@ -74,7 +118,7 @@ public static function trace(?HubInterface $hub = null): \Closure } } - $handlerPromiseCallback = static function ($responseOrException) use ($hub, $spanAndBreadcrumbData, $childSpan, $parentSpan, $partialUri) { + $handlerPromiseCallback = static function ($responseOrException) use ($hub, $spanAndBreadcrumbData, $spanData, $childSpan, $parentSpan, $collectedUri, $dataCollection) { if ($childSpan !== null) { // We finish the span (which means setting the span end timestamp) first to ensure the measured time // the span spans is as close to only the HTTP request time and do the data collection afterwards @@ -83,6 +127,7 @@ public static function trace(?HubInterface $hub = null): \Closure $hub->setSpan($parentSpan); } + /** @var ResponseInterface|null $response */ $response = null; if ($responseOrException instanceof ResponseInterface) { @@ -93,21 +138,28 @@ public static function trace(?HubInterface $hub = null): \Closure $breadcrumbLevel = Breadcrumb::LEVEL_INFO; - if ($response !== null) { - $spanAndBreadcrumbData['http.response.body.size'] = $response->getBody()->getSize(); - $spanAndBreadcrumbData['http.response.status_code'] = $response->getStatusCode(); + if ($response instanceof ResponseInterface) { + $responseBody = $response->getBody(); + $statusCode = $response->getStatusCode(); + $spanAndBreadcrumbData['http.response.body.size'] = $responseBody->getSize(); + $spanAndBreadcrumbData['http.response.status_code'] = $statusCode; - if ($response->getStatusCode() >= 400 && $response->getStatusCode() < 500) { + if ($statusCode >= 400 && $statusCode < 500) { $breadcrumbLevel = Breadcrumb::LEVEL_WARNING; - } elseif ($response->getStatusCode() >= 500) { + } elseif ($statusCode >= 500) { $breadcrumbLevel = Breadcrumb::LEVEL_ERROR; } } if ($childSpan !== null) { - if ($response !== null) { + if ($response instanceof ResponseInterface) { + $spanData = array_merge( + $spanData, + $spanAndBreadcrumbData, + self::collectResponseSpanData($dataCollection, $response) + ); $childSpan->setStatus(SpanStatus::createFromHttpStatusCode($response->getStatusCode())); - $childSpan->setData($spanAndBreadcrumbData); + $childSpan->setData($spanData); } else { $childSpan->setStatus(SpanStatus::internalError()); } @@ -119,7 +171,7 @@ public static function trace(?HubInterface $hub = null): \Closure 'http', null, array_merge([ - 'url' => (string) $partialUri, + 'url' => (string) $collectedUri, ], $spanAndBreadcrumbData) )); @@ -135,16 +187,207 @@ public static function trace(?HubInterface $hub = null): \Closure }; } - private static function shouldAttachTracingHeaders(?ClientInterface $client, RequestInterface $request): bool + private static function collectQueryString(?DataCollectionOptions $dataCollection, string $queryString): ?string { - if ($client === null) { - return false; + if ($queryString === '') { + return null; + } + + if ($dataCollection === null) { + return $queryString; + } + + return KeyValueDataFilter::filterQueryString($queryString, $dataCollection->getUrlQueryParams()); + } + + /** + * @param 'none'|'never'|'small'|'medium'|'always' $maxRequestBodySize + * + * @return array + */ + private static function collectRequestSpanData( + DataCollectionOptions $dataCollection, + string $maxRequestBodySize, + RequestInterface $request, + StreamInterface $body + ): array { + $data = self::collectHeaders($dataCollection, $request->getHeaders(), 'request'); + + if (!\in_array('outgoingRequest', $dataCollection->getHttpBodies(), true)) { + return $data; + } + + $maxBodyLength = self::MAX_REQUEST_BODY_SIZE_TO_LENGTH[$maxRequestBodySize]; + $collectedBody = self::collectBody($body, $request->getHeaderLine('Content-Type'), $maxBodyLength); + + if ($collectedBody !== null) { + $data['http.request.body.data'] = $collectedBody; + } + + return $data; + } + + /** + * @return array + */ + private static function collectResponseSpanData(?DataCollectionOptions $dataCollection, ResponseInterface $response): array + { + if ($dataCollection === null) { + return []; + } + + $data = self::collectHeaders($dataCollection, $response->getHeaders(), 'response'); + + if (!\in_array('incomingResponse', $dataCollection->getHttpBodies(), true)) { + return $data; + } + + $collectedBody = self::collectBody( + $response->getBody(), + $response->getHeaderLine('Content-Type'), + self::HTTP_BODY_MAX_CONTENT_LENGTH + ); + + if ($collectedBody !== null) { + $data['http.response.body.data'] = $collectedBody; + } + + return $data; + } + + /** + * @param array $headers + * @param 'request'|'response' $direction + * + * @return array + */ + private static function collectHeaders(DataCollectionOptions $dataCollection, array $headers, string $direction): array + { + $headerBehavior = $dataCollection->getHttpHeaders()[$direction]; + $cookieBehavior = $dataCollection->getCookies(); + $prefix = 'http.' . $direction . '.header.'; + $regularHeaders = []; + $attributes = []; + + foreach ($headers as $name => $values) { + $name = strtolower((string) $name); + + if ($name === 'cookie' || $name === 'set-cookie') { + if ($cookieBehavior['mode'] !== 'off' && $values !== []) { + // PSR-7 exposes cookies as raw header strings, so use the safe fallback required by the data collection spec. + $attributes[$prefix . $name] = array_fill(0, \count($values), KeyValueDataFilter::FILTERED_VALUE); + } + + continue; + } + + $regularHeaders[$name] = $values; } - $sdkOptions = $client->getOptions(); + $filteredHeaders = KeyValueDataFilter::filterHeaders($regularHeaders, $headerBehavior); + foreach ($filteredHeaders ?? [] as $name => $values) { + $attributes[$prefix . $name] = $values; + } + + return $attributes; + } + + /** + * @return array|string|null + */ + private static function collectBody(StreamInterface $body, string $contentType, int $maxBodyLength) + { + if ($maxBodyLength === 0) { + return null; + } + + $bodySize = $body->getSize(); + if ($bodySize === 0 || ($bodySize !== null && $bodySize > $maxBodyLength)) { + return null; + } + + $mediaType = strtolower(trim(explode(';', $contentType, 2)[0])); + + $isJson = $mediaType === 'application/json' + // RFC 6839 structured syntax suffix, e.g. application/problem+json. + || substr($mediaType, -5) === '+json'; + $isForm = $mediaType === 'application/x-www-form-urlencoded'; + + if (!$isJson && !$isForm) { + return KeyValueDataFilter::FILTERED_VALUE; + } + + // The size can be unknown (a null body size), so readBody() enforces the limit again after reading. + $bodyContents = self::readBody($body, $maxBodyLength); + if ($bodyContents === null) { + return null; + } + + try { + if ($isJson) { + /** @mago-ignore analysis:mixed-assignment */ + $decodedBody = JSON::decode($bodyContents); + } else { + /** @var array $decodedBody */ + $decodedBody = Query::parse($bodyContents); + } + } catch (\Throwable $exception) { + return KeyValueDataFilter::FILTERED_VALUE; + } + + if (!\is_array($decodedBody)) { + return KeyValueDataFilter::FILTERED_VALUE; + } + + return KeyValueDataFilter::filterHttpBodyData($decodedBody); + } + + private static function readBody(StreamInterface $body, int $maxBodyLength): ?string + { + if (!$body->isReadable() || !$body->isSeekable()) { + return null; + } + + $position = null; + + try { + $position = $body->tell(); + $body->rewind(); + + // Read one byte past the limit to detect bodies of unknown size that exceed it. + $contents = Utils::copyToString($body, $maxBodyLength + 1); + + if ($contents === '' || \strlen($contents) > $maxBodyLength) { + return null; + } + + return $contents; + } catch (\Throwable $exception) { + return null; + } finally { + if ($position !== null) { + self::restoreBodyPosition($body, $position); + } + } + } + + private static function restoreBodyPosition(StreamInterface $body, int $position): void + { + try { + $body->seek($position); + } catch (\Throwable $exception) { + // Ignore streams that report themselves as seekable but cannot be restored. + } + } + + private static function shouldAttachTracingHeaders(?Options $options, RequestInterface $request): bool + { + if ($options === null) { + return false; + } // Check if the request destination is allow listed in the trace_propagation_targets option. - return $sdkOptions->getTracePropagationTargets() === null - || \in_array($request->getUri()->getHost(), $sdkOptions->getTracePropagationTargets()); + return $options->getTracePropagationTargets() === null + || \in_array($request->getUri()->getHost(), $options->getTracePropagationTargets()); } } diff --git a/src/Util/Arr.php b/src/Util/Arr.php index 14f9594e5..2c0fde8de 100644 --- a/src/Util/Arr.php +++ b/src/Util/Arr.php @@ -45,7 +45,7 @@ public static function simpleDot(array $array): array /** * Checks whether a given array is a list. * - * `array_is_list` is introduced in PHP 8.1, so we have a polyfill for it. + * Uses `array_is_list` when available and falls back to a PHP 7.2-compatible implementation. * * @see https://www.php.net/manual/en/function.array-is-list.php#126794 * @@ -53,6 +53,10 @@ public static function simpleDot(array $array): array */ public static function isList(array $array): bool { + if (\function_exists('array_is_list')) { + return array_is_list($array); + } + $i = 0; foreach ($array as $k => $v) { diff --git a/tests/DataCollection/KeyValueDataFilterTest.php b/tests/DataCollection/KeyValueDataFilterTest.php index 4352e28fa..ea56ad62a 100644 --- a/tests/DataCollection/KeyValueDataFilterTest.php +++ b/tests/DataCollection/KeyValueDataFilterTest.php @@ -96,6 +96,23 @@ public function testFilterKeyValueDataFiltersNestedData(): void ], $filtered); } + public function testFilterHttpBodyDataFiltersSensitiveAndUnkeyedValues(): void + { + $this->assertSame([ + [ + 'password' => '[Filtered]', + 'name' => 'alice', + ], + '[Filtered]', + ], KeyValueDataFilter::filterHttpBodyData([ + [ + 'password' => 'secret', + 'name' => 'alice', + ], + 'unkeyed secret', + ])); + } + public function testFilterHeadersReturnsNullWhenCollectionIsOff(): void { $behavior = ['mode' => 'off', 'terms' => ['x-request-id']]; diff --git a/tests/Tracing/GuzzleTracingMiddlewareTest.php b/tests/Tracing/GuzzleTracingMiddlewareTest.php index becfa84a8..f8c2106c2 100644 --- a/tests/Tracing/GuzzleTracingMiddlewareTest.php +++ b/tests/Tracing/GuzzleTracingMiddlewareTest.php @@ -7,9 +7,12 @@ use GuzzleHttp\Promise\FulfilledPromise; use GuzzleHttp\Promise\PromiseInterface; use GuzzleHttp\Promise\RejectedPromise; +use GuzzleHttp\Psr7\FnStream; +use GuzzleHttp\Psr7\NoSeekStream; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; use GuzzleHttp\Psr7\Uri; +use GuzzleHttp\Psr7\Utils; use PHPUnit\Framework\TestCase; use Sentry\ClientInterface; use Sentry\Event; @@ -19,7 +22,9 @@ use Sentry\State\Hub; use Sentry\State\Scope; use Sentry\Tracing\GuzzleTracingMiddleware; +use Sentry\Tracing\Span; use Sentry\Tracing\SpanStatus; +use Sentry\Tracing\Transaction; use Sentry\Tracing\TransactionContext; final class GuzzleTracingMiddlewareTest extends TestCase @@ -402,6 +407,424 @@ public function testTrace(Request $request, $expectedPromiseResult, array $expec $transaction->finish(); } + /** + * @dataProvider traceQueryStringDataProvider + * + * @param array $options + */ + public function testTraceFiltersQueryString(array $options, ?string $expectedQueryString): void + { + $rawQueryString = 'search=hello%20world&password=s%2Becret&custom=value'; + $sdkOptions = new Options(array_merge([ + 'traces_sample_rate' => 1, + ], $options)); + $client = $this->createMock(ClientInterface::class); + $client->expects($this->atLeastOnce()) + ->method('getOptions') + ->willReturn($sdkOptions); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + $transaction = $hub->startTransaction(new TransactionContext()); + $hub->setSpan($transaction); + + $middleware = GuzzleTracingMiddleware::trace($hub); + $function = $middleware(function (Request $request) use ($rawQueryString): PromiseInterface { + $this->assertSame($rawQueryString, $request->getUri()->getQuery()); + + return new FulfilledPromise(new Response()); + }); + + /** @var PromiseInterface $promise */ + $promise = $function(new Request('GET', 'https://www.example.com?' . $rawQueryString), []); + $promise->wait(); + + $spanData = $this->getHttpSpan($transaction)->getData(); + $breadcrumbData = $this->getBreadcrumbData($hub); + + if ($expectedQueryString === null) { + $this->assertArrayNotHasKey('http.query', $spanData); + $this->assertArrayNotHasKey('http.query', $breadcrumbData); + } else { + $this->assertSame($expectedQueryString, $spanData['http.query']); + $this->assertSame($expectedQueryString, $breadcrumbData['http.query']); + } + } + + public function testTraceCollectsConfiguredOutgoingHttpData(): void + { + $sdkOptions = new Options([ + 'traces_sample_rate' => 1, + 'data_collection' => [], + ]); + $client = $this->createMock(ClientInterface::class); + $client->expects($this->atLeastOnce()) + ->method('getOptions') + ->willReturn($sdkOptions); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + $transaction = $hub->startTransaction(new TransactionContext()); + $hub->setSpan($transaction); + + $response = new Response(200, [ + 'Content-Type' => 'application/x-www-form-urlencoded', + 'X-Response-Id' => 'response-123', + 'Set-Cookie' => [ + 'session_id=response-secret; Path=/; HttpOnly', + 'theme=light; Path=/', + ], + ], 'token=response-secret&status=ok'); + $middleware = GuzzleTracingMiddleware::trace($hub); + $function = $middleware(function (Request $request) use ($response): PromiseInterface { + $this->assertSame(0, $request->getBody()->tell()); + + return new FulfilledPromise($response); + }); + $request = new Request( + 'POST', + 'https://www.example.com/path?search=hello%20world&password=request-secret#fragment', + [ + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer request-secret', + 'Cookie' => 'session_id=request-secret; theme=dark', + ], + '[{"password":"request-secret","name":"Alice"},"unkeyed-request-secret"]' + ); + + /** @var PromiseInterface $promise */ + $promise = $function($request, []); + $promise->wait(); + + $this->assertSame(0, $request->getBody()->tell()); + $this->assertSame(0, $response->getBody()->tell()); + + $expectedSharedData = [ + 'url.full' => 'https://www.example.com/path?search=hello%20world&password=%5BFiltered%5D#fragment', + 'http.query' => 'search=hello%20world&password=[Filtered]', + ]; + $expectedSpanData = [ + 'http.request.header.content-type' => ['application/json'], + 'http.request.header.authorization' => ['[Filtered]'], + 'http.request.header.cookie' => ['[Filtered]'], + 'http.request.body.data' => [ + [ + 'password' => '[Filtered]', + 'name' => 'Alice', + ], + '[Filtered]', + ], + 'http.response.header.content-type' => ['application/x-www-form-urlencoded'], + 'http.response.header.x-response-id' => ['response-123'], + 'http.response.header.set-cookie' => ['[Filtered]', '[Filtered]'], + 'http.response.body.data' => [ + 'token' => '[Filtered]', + 'status' => 'ok', + ], + ]; + $spanData = $this->getHttpSpan($transaction)->getData(); + $breadcrumbData = $this->getBreadcrumbData($hub); + + foreach ($expectedSharedData as $key => $value) { + $this->assertSame($value, $spanData[$key]); + $this->assertSame($value, $breadcrumbData[$key]); + } + foreach ($expectedSpanData as $key => $value) { + $this->assertSame($value, $spanData[$key]); + $this->assertArrayNotHasKey($key, $breadcrumbData); + } + $this->assertSame($expectedSharedData['url.full'], $breadcrumbData['url']); + $this->assertStringNotContainsString('request-secret', json_encode($spanData)); + $this->assertStringNotContainsString('response-secret', json_encode($spanData)); + } + + public function testTraceDoesNotConsumeNonSeekableBodies(): void + { + $sdkOptions = new Options([ + 'traces_sample_rate' => 1, + 'data_collection' => [], + ]); + $client = $this->createMock(ClientInterface::class); + $client->expects($this->atLeastOnce()) + ->method('getOptions') + ->willReturn($sdkOptions); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + $transaction = $hub->startTransaction(new TransactionContext()); + $hub->setSpan($transaction); + + $requestBody = new NoSeekStream(Utils::streamFor('{"request":"body"}')); + $responseBody = new NoSeekStream(Utils::streamFor('{"response":"body"}')); + $response = new Response(200, ['Content-Type' => 'application/json'], $responseBody); + $middleware = GuzzleTracingMiddleware::trace($hub); + $function = $middleware(function (Request $request) use ($response): PromiseInterface { + $this->assertSame('{"request":"body"}', $request->getBody()->getContents()); + + return new FulfilledPromise($response); + }); + + /** @var PromiseInterface $promise */ + $promise = $function(new Request( + 'POST', + 'https://www.example.com', + ['Content-Type' => 'application/json'], + $requestBody + ), []); + $promiseResult = $promise->wait(); + + $this->assertSame($response, $promiseResult); + $this->assertSame('{"response":"body"}', $promiseResult->getBody()->getContents()); + + $spanData = $this->getHttpSpan($transaction)->getData(); + $this->assertArrayNotHasKey('http.request.body.data', $spanData); + $this->assertArrayNotHasKey('http.response.body.data', $spanData); + } + + public function testTraceSkipsBodiesLargerThanTheirLimits(): void + { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->atLeastOnce()) + ->method('getOptions') + ->willReturn(new Options([ + 'traces_sample_rate' => 1, + 'data_collection' => [], + ])); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + $transaction = $hub->startTransaction(new TransactionContext()); + $hub->setSpan($transaction); + + $oversizedRequestBody = str_repeat('a', 10001); + $oversizedResponseBody = str_repeat('a', 100001); + $response = new Response(200, ['Content-Type' => 'application/json'], $oversizedResponseBody); + $middleware = GuzzleTracingMiddleware::trace($hub); + $function = $middleware(static function () use ($response): PromiseInterface { + return new FulfilledPromise($response); + }); + + /** @var PromiseInterface $promise */ + $promise = $function(new Request( + 'POST', + 'https://www.example.com', + ['Content-Type' => 'application/json'], + $oversizedRequestBody + ), []); + $promise->wait(); + + $spanData = $this->getHttpSpan($transaction)->getData(); + $this->assertArrayNotHasKey('http.request.body.data', $spanData); + $this->assertArrayNotHasKey('http.response.body.data', $spanData); + } + + /** + * @dataProvider httpBodySafetyLimitDataProvider + */ + public function testTraceAppliesHttpBodySafetyLimit(int $bodySize, bool $shouldCollect): void + { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->atLeastOnce()) + ->method('getOptions') + ->willReturn(new Options([ + 'traces_sample_rate' => 1, + 'max_request_body_size' => 'always', + 'data_collection' => [], + ])); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + $transaction = $hub->startTransaction(new TransactionContext()); + $hub->setSpan($transaction); + + $rawBody = str_repeat('a', $bodySize); + $requestBody = FnStream::decorate(Utils::streamFor($rawBody), [ + 'getSize' => static function (): ?int { + return null; + }, + ]); + $responseBody = FnStream::decorate(Utils::streamFor($rawBody), [ + 'getSize' => static function (): ?int { + return null; + }, + ]); + $response = new Response(200, ['Content-Type' => 'application/json'], $responseBody); + $middleware = GuzzleTracingMiddleware::trace($hub); + $function = $middleware(static function () use ($response): PromiseInterface { + return new FulfilledPromise($response); + }); + + /** @var PromiseInterface $promise */ + $promise = $function(new Request( + 'POST', + 'https://www.example.com', + ['Content-Type' => 'application/json'], + $requestBody + ), []); + $promise->wait(); + + $this->assertSame(0, $requestBody->tell()); + $this->assertSame(0, $responseBody->tell()); + + $spanData = $this->getHttpSpan($transaction)->getData(); + if ($shouldCollect) { + $this->assertSame('[Filtered]', $spanData['http.request.body.data']); + $this->assertSame('[Filtered]', $spanData['http.response.body.data']); + } else { + $this->assertArrayNotHasKey('http.request.body.data', $spanData); + $this->assertArrayNotHasKey('http.response.body.data', $spanData); + } + } + + public static function httpBodySafetyLimitDataProvider(): iterable + { + yield 'at 100 KB safety limit' => [100000, true]; + yield 'over 100 KB safety limit' => [100001, false]; + } + + public function testTraceRespectsDisabledOutgoingHttpDataCollection(): void + { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->atLeastOnce()) + ->method('getOptions') + ->willReturn(new Options([ + 'traces_sample_rate' => 1, + 'data_collection' => [ + 'cookies' => ['mode' => 'off'], + 'http_headers' => [ + 'request' => ['mode' => 'off'], + 'response' => ['mode' => 'off'], + ], + 'http_bodies' => [], + 'url_query_params' => ['mode' => 'off'], + ], + ])); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + $transaction = $hub->startTransaction(new TransactionContext()); + $hub->setSpan($transaction); + + $response = new Response(200, [ + 'Content-Type' => 'application/json', + 'Set-Cookie' => 'session_id=response-secret', + ], '{"token":"response-secret"}'); + $middleware = GuzzleTracingMiddleware::trace($hub); + $function = $middleware(static function () use ($response): PromiseInterface { + return new FulfilledPromise($response); + }); + + /** @var PromiseInterface $promise */ + $promise = $function(new Request( + 'POST', + 'https://www.example.com?password=request-secret', + [ + 'Content-Type' => 'application/json', + 'Cookie' => 'session_id=request-secret', + ], + '{"password":"request-secret"}' + ), []); + $promise->wait(); + + $spanData = $this->getHttpSpan($transaction)->getData(); + $breadcrumbData = $this->getBreadcrumbData($hub); + + foreach ([ + 'http.query', + 'http.request.header.content-type', + 'http.request.header.cookie', + 'http.request.body.data', + 'http.response.header.content-type', + 'http.response.header.set-cookie', + 'http.response.body.data', + ] as $key) { + $this->assertArrayNotHasKey($key, $spanData); + $this->assertArrayNotHasKey($key, $breadcrumbData); + } + } + + /** + * @return array + */ + private function getBreadcrumbData(Hub $hub): array + { + $event = Event::createEvent(); + $hub->configureScope(static function (Scope $scope) use ($event): void { + $scope->applyToEvent($event); + }); + $this->assertCount(1, $event->getBreadcrumbs()); + + return $event->getBreadcrumbs()[0]->getMetadata(); + } + + private function getHttpSpan(Transaction $transaction): Span + { + $this->assertNotNull($transaction->getSpanRecorder()); + $httpSpans = array_values(array_filter( + $transaction->getSpanRecorder()->getSpans(), + static function (Span $span): bool { + return $span->getOp() === 'http.client'; + } + )); + $this->assertCount(1, $httpSpans); + + return $httpSpans[0]; + } + + public static function traceQueryStringDataProvider(): iterable + { + yield 'legacy behavior is unchanged' => [ + [], + 'search=hello%20world&password=s%2Becret&custom=value', + ]; + + yield 'default data collection filters mandatory sensitive values' => [ + ['data_collection' => []], + 'search=hello%20world&password=[Filtered]&custom=value', + ]; + + yield 'collection can be disabled' => [ + [ + 'data_collection' => [ + 'url_query_params' => [ + 'mode' => 'off', + ], + ], + ], + null, + ]; + + yield 'allow list filters values not matching configured terms' => [ + [ + 'data_collection' => [ + 'url_query_params' => [ + 'mode' => 'allowList', + 'terms' => ['custom'], + ], + ], + ], + 'search=[Filtered]&password=[Filtered]&custom=value', + ]; + + yield 'deny list combines mandatory and custom terms' => [ + [ + 'data_collection' => [ + 'url_query_params' => [ + 'mode' => 'denyList', + 'terms' => ['custom'], + ], + ], + ], + 'search=hello%20world&password=[Filtered]&custom=[Filtered]', + ]; + } + public static function traceDataProvider(): iterable { yield [