From 3b6931aa2abc0f7ebdd994e44b1acb6ba550d563 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 7 Sep 2026 15:05:01 +0200 Subject: [PATCH 1/4] feat(pii): add data processors that are shared within framework SDKs --- src/DataCollection/HttpDataCollector.php | 98 +++++++++++++ src/DataCollection/HttpHeaderNormalizer.php | 73 ++++++++++ src/DataCollection/RequestDataCollector.php | 13 +- src/Integration/RequestIntegration.php | 5 +- src/Tracing/GuzzleTracingMiddleware.php | 75 ++-------- .../DataCollection/HttpDataCollectorTest.php | 106 ++++++++++++++ .../HttpHeaderNormalizerTest.php | 135 ++++++++++++++++++ tests/Integration/RequestIntegrationTest.php | 6 +- tests/Tracing/GuzzleTracingMiddlewareTest.php | 39 ++++- 9 files changed, 470 insertions(+), 80 deletions(-) create mode 100644 src/DataCollection/HttpDataCollector.php create mode 100644 src/DataCollection/HttpHeaderNormalizer.php create mode 100644 tests/DataCollection/HttpDataCollectorTest.php create mode 100644 tests/DataCollection/HttpHeaderNormalizerTest.php diff --git a/src/DataCollection/HttpDataCollector.php b/src/DataCollection/HttpDataCollector.php new file mode 100644 index 000000000..f6f77e0c5 --- /dev/null +++ b/src/DataCollection/HttpDataCollector.php @@ -0,0 +1,98 @@ +getUrlQueryParams()); + } + + public static function collectUrl(?DataCollectionOptions $dataCollection, string $url): string + { + if ($dataCollection === null) { + return $url; + } + + $uri = new Uri($url); + $query = self::collectQueryString($dataCollection, (string) parse_url($url, \PHP_URL_QUERY)); + $result = (string) $uri->withUserInfo('')->withQuery('')->withFragment(''); + + if ($query !== null && $query !== '') { + $result .= '?' . $query; + } + + if ($uri->getFragment() !== '') { + $result .= '#' . $uri->getFragment(); + } + + return $result; + } + + /** + * @param array $headers + * @param 'request'|'response' $direction + * + * @return array + */ + public 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 !== []) { + // Raw cookie headers cannot be filtered by individual cookie name. + $attributes[$prefix . $name] = array_fill(0, \count($values), KeyValueDataFilter::FILTERED_VALUE); + } + + continue; + } + + $regularHeaders[$name] = $values; + } + + $filteredHeaders = KeyValueDataFilter::filterHeaders($regularHeaders, $headerBehavior); + foreach ($filteredHeaders ?? [] as $name => $values) { + $attributes[$prefix . $name] = $values; + } + + return $attributes; + } + + /** + * @param array $data + */ + public static function setMissingSpanData(Span $span, array $data): void + { + $span->setData(array_diff_key($data, $span->getData())); + } +} diff --git a/src/DataCollection/HttpHeaderNormalizer.php b/src/DataCollection/HttpHeaderNormalizer.php new file mode 100644 index 000000000..b4fca59a1 --- /dev/null +++ b/src/DataCollection/HttpHeaderNormalizer.php @@ -0,0 +1,73 @@ + $headers + * + * @return array + */ + public static function normalize(array $headers): array + { + $normalized = []; + + foreach ($headers as $name => $values) { + // Numeric keys with array values can be valid header names in a + // header map; only scalar entries are interpreted as raw lines. + if (\is_int($name) && !\is_array($values)) { + if (!\is_string($values)) { + continue; + } + + $parsedHeaders = []; + Http::parseResponseHeaders($values, $parsedHeaders); + foreach ($parsedHeaders as $parsedName => $parsedValues) { + self::appendHeader($normalized, (string) $parsedName, $parsedValues); + } + + continue; + } + + self::appendHeader($normalized, (string) $name, \is_array($values) ? $values : [$values]); + } + + return $normalized; + } + + /** + * @param array $normalized + * @param array $values + */ + private static function appendHeader(array &$normalized, string $name, array $values): void + { + $name = strtolower(trim($name)); + if ($name === '') { + return; + } + + foreach ($values as $value) { + // Header bags may contain nulls or objects. Do not call + // __toString or retain objects for later serialization. + $normalized[$name][] = \is_scalar($value) ? (string) $value : KeyValueDataFilter::FILTERED_VALUE; + } + } +} diff --git a/src/DataCollection/RequestDataCollector.php b/src/DataCollection/RequestDataCollector.php index 45cd82d67..dfe065d4c 100644 --- a/src/DataCollection/RequestDataCollector.php +++ b/src/DataCollection/RequestDataCollector.php @@ -68,18 +68,7 @@ public function shouldCollectUserInfo(): bool public function collectQueryString(string $queryString): ?string { - if ($this->dataCollection === null) { - return $queryString !== '' ? $queryString : null; - } - - if ($queryString === '') { - return null; - } - - return KeyValueDataFilter::filterQueryString( - $queryString, - $this->dataCollection->getUrlQueryParams() - ); + return HttpDataCollector::collectQueryString($this->dataCollection, $queryString); } /** diff --git a/src/Integration/RequestIntegration.php b/src/Integration/RequestIntegration.php index befd68e64..e4778a5f8 100644 --- a/src/Integration/RequestIntegration.php +++ b/src/Integration/RequestIntegration.php @@ -6,6 +6,7 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\UploadedFileInterface; +use Sentry\DataCollection\HttpDataCollector; use Sentry\DataCollection\RequestDataCollector; use Sentry\Event; use Sentry\Exception\JsonException; @@ -124,9 +125,7 @@ private function processEvent(Event $event, Options $options): void $queryString = $collector->collectQueryString($request->getUri()->getQuery()); $requestData = [ - 'url' => $collector->usesDataCollection() - ? (string) $request->getUri()->withQuery($queryString ?? '') - : (string) $request->getUri(), + 'url' => HttpDataCollector::collectUrl($options->getDataCollection(), (string) $request->getUri()), 'method' => $request->getMethod(), ]; diff --git a/src/Tracing/GuzzleTracingMiddleware.php b/src/Tracing/GuzzleTracingMiddleware.php index a883dee22..2b01a537a 100644 --- a/src/Tracing/GuzzleTracingMiddleware.php +++ b/src/Tracing/GuzzleTracingMiddleware.php @@ -13,6 +13,7 @@ use Psr\Http\Message\StreamInterface; use Sentry\Breadcrumb; use Sentry\DataCollection\DataCollectionOptions; +use Sentry\DataCollection\HttpDataCollector; use Sentry\DataCollection\KeyValueDataFilter; use Sentry\Options; use Sentry\SentrySdk; @@ -62,7 +63,7 @@ public static function trace(?HubInterface $hub = null): \Closure 'http.request.body.size' => $requestBody->getSize(), ]; - $queryString = self::collectQueryString($dataCollection, $requestUri->getQuery()); + $queryString = HttpDataCollector::collectQueryString($dataCollection, $requestUri->getQuery()); if ($queryString !== null) { $spanAndBreadcrumbData['http.query'] = $queryString; } @@ -70,12 +71,10 @@ public static function trace(?HubInterface $hub = null): \Closure $spanAndBreadcrumbData['http.fragment'] = $requestUri->getFragment(); } - $collectedUri = $partialUri; + $collectedUrl = (string) $partialUri; if ($dataCollection !== null) { - $collectedUri = $collectedUri - ->withQuery($queryString ?? '') - ->withFragment($requestUri->getFragment()); - $spanAndBreadcrumbData['url.full'] = (string) $collectedUri; + $collectedUrl = HttpDataCollector::collectUrl($dataCollection, (string) $requestUri); + $spanAndBreadcrumbData['url.full'] = $collectedUrl; } $childSpan = null; @@ -118,7 +117,7 @@ public static function trace(?HubInterface $hub = null): \Closure } } - $handlerPromiseCallback = static function ($responseOrException) use ($hub, $spanAndBreadcrumbData, $spanData, $childSpan, $parentSpan, $collectedUri, $dataCollection) { + $handlerPromiseCallback = static function ($responseOrException) use ($hub, $spanAndBreadcrumbData, $spanData, $childSpan, $parentSpan, $collectedUrl, $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 @@ -159,7 +158,11 @@ public static function trace(?HubInterface $hub = null): \Closure self::collectResponseSpanData($dataCollection, $response) ); $childSpan->setStatus(SpanStatus::createFromHttpStatusCode($response->getStatusCode())); - $childSpan->setData($spanData); + if ($dataCollection === null) { + $childSpan->setData($spanData); + } else { + HttpDataCollector::setMissingSpanData($childSpan, $spanData); + } } else { $childSpan->setStatus(SpanStatus::internalError()); } @@ -171,7 +174,7 @@ public static function trace(?HubInterface $hub = null): \Closure 'http', null, array_merge([ - 'url' => (string) $collectedUri, + 'url' => $collectedUrl, ], $spanAndBreadcrumbData) )); @@ -187,19 +190,6 @@ public static function trace(?HubInterface $hub = null): \Closure }; } - private static function collectQueryString(?DataCollectionOptions $dataCollection, string $queryString): ?string - { - if ($queryString === '') { - return null; - } - - if ($dataCollection === null) { - return $queryString; - } - - return KeyValueDataFilter::filterQueryString($queryString, $dataCollection->getUrlQueryParams()); - } - /** * @param 'none'|'never'|'small'|'medium'|'always' $maxRequestBodySize * @@ -211,7 +201,7 @@ private static function collectRequestSpanData( RequestInterface $request, StreamInterface $body ): array { - $data = self::collectHeaders($dataCollection, $request->getHeaders(), 'request'); + $data = HttpDataCollector::collectHeaders($dataCollection, $request->getHeaders(), 'request'); if (!\in_array('outgoingRequest', $dataCollection->getHttpBodies(), true)) { return $data; @@ -236,7 +226,7 @@ private static function collectResponseSpanData(?DataCollectionOptions $dataColl return []; } - $data = self::collectHeaders($dataCollection, $response->getHeaders(), 'response'); + $data = HttpDataCollector::collectHeaders($dataCollection, $response->getHeaders(), 'response'); if (!\in_array('incomingResponse', $dataCollection->getHttpBodies(), true)) { return $data; @@ -255,43 +245,6 @@ private static function collectResponseSpanData(?DataCollectionOptions $dataColl 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; - } - - $filteredHeaders = KeyValueDataFilter::filterHeaders($regularHeaders, $headerBehavior); - foreach ($filteredHeaders ?? [] as $name => $values) { - $attributes[$prefix . $name] = $values; - } - - return $attributes; - } - /** * @return array|string|null */ diff --git a/tests/DataCollection/HttpDataCollectorTest.php b/tests/DataCollection/HttpDataCollectorTest.php new file mode 100644 index 000000000..f5f7d8433 --- /dev/null +++ b/tests/DataCollection/HttpDataCollectorTest.php @@ -0,0 +1,106 @@ +assertSame('token=secret&q=a%20b', HttpDataCollector::collectQueryString(null, 'token=secret&q=a%20b')); + $this->assertNull(HttpDataCollector::collectQueryString(null, '')); + } + + public function testCollectQueryStringPreservesEncoding(): void + { + $this->assertSame( + 'api%5Ftoken=[Filtered]&q=a%20b%26c', + HttpDataCollector::collectQueryString(new DataCollectionOptions(), 'api%5Ftoken=secret&q=a%20b%26c') + ); + } + + public function testEmptyAndDisabledQueryStringsAreNotCollected(): void + { + $this->assertNull(HttpDataCollector::collectQueryString(new DataCollectionOptions(), '')); + $this->assertNull(HttpDataCollector::collectQueryString(new DataCollectionOptions(['url_query_params' => ['mode' => 'off']]), 'token=secret')); + } + + /** + * @dataProvider urlDataProvider + * + * @param array|null $options + */ + public function testCollectUrl(?array $options, string $url, string $expected): void + { + $this->assertSame($expected, HttpDataCollector::collectUrl($options === null ? null : new DataCollectionOptions($options), $url)); + } + + /** + * @return \Generator|null, string, string}> + */ + public function urlDataProvider(): \Generator + { + $url = 'https://user:password@example.com/a?z=a%20b%26c&api%5Ftoken=secret&z=x+y#fragment'; + yield 'legacy URL is unchanged' => [null, $url, $url]; + yield 'filter without re-encoding' => [[], $url, 'https://example.com/a?z=a%20b%26c&api%5Ftoken=[Filtered]&z=x+y#fragment']; + yield 'query collection disabled' => [['url_query_params' => ['mode' => 'off']], $url, 'https://example.com/a#fragment']; + yield 'remove credentials without a query' => [[], 'https://user:password@example.com/a', 'https://example.com/a']; + yield 'relative URL' => [[], '/a?token=secret', '/a?token=[Filtered]']; + } + + public function testCollectHeadersFiltersSensitiveHeadersAndCookies(): void + { + $this->assertSame([ + 'http.request.header.cookie' => ['[Filtered]', '[Filtered]'], + 'http.request.header.authorization' => ['[Filtered]'], + 'http.request.header.x-request-id' => ['request-id'], + ], HttpDataCollector::collectHeaders(new DataCollectionOptions(), [ + 'Authorization' => ['Bearer secret'], + 'X-Request-ID' => ['request-id'], + 'Cookie' => ['session_id=secret', 'theme=dark'], + ], 'request')); + } + + public function testHeaderDirectionsAndCookiesAreIndependent(): void + { + $options = new DataCollectionOptions([ + 'cookies' => ['mode' => 'off'], + 'http_headers' => [ + 'request' => ['mode' => 'off'], + 'response' => ['mode' => 'allowList', 'terms' => ['x-test', 'authorization']], + ], + ]); + $headers = ['x-test' => ['plain'], 'x-other' => ['other'], 'authorization' => ['secret'], 'set-cookie' => ['theme=dark']]; + $this->assertSame([], HttpDataCollector::collectHeaders($options, $headers, 'request')); + $this->assertSame([ + 'http.response.header.x-test' => ['plain'], + 'http.response.header.x-other' => ['[Filtered]'], + 'http.response.header.authorization' => ['[Filtered]'], + ], HttpDataCollector::collectHeaders($options, $headers, 'response')); + $options = new DataCollectionOptions(['http_headers' => ['mode' => 'off']]); + $this->assertSame(['http.response.header.set-cookie' => ['[Filtered]']], HttpDataCollector::collectHeaders($options, $headers, 'response')); + $this->assertSame([], HttpDataCollector::collectHeaders($options, ['cookie' => []], 'request')); + } + + public function testAutomaticSpanDataOnlyFillsMissingFields(): void + { + $span = new Span(); + $span->setData(['http.response.header.x-test' => ['explicit'], 'http.response.body.data' => null]); + HttpDataCollector::setMissingSpanData($span, [ + 'http.response.header.x-test' => ['automatic'], + 'http.response.body.data' => ['name' => 'automatic'], + 'http.response.header.content-type' => ['application/json'], + ]); + $this->assertSame([ + 'http.response.header.x-test' => ['explicit'], + 'http.response.body.data' => null, + 'http.response.header.content-type' => ['application/json'], + ], $span->getData()); + } +} diff --git a/tests/DataCollection/HttpHeaderNormalizerTest.php b/tests/DataCollection/HttpHeaderNormalizerTest.php new file mode 100644 index 000000000..bb28113e0 --- /dev/null +++ b/tests/DataCollection/HttpHeaderNormalizerTest.php @@ -0,0 +1,135 @@ +assertSame([ + 'content-type' => ['application/json'], + 'x-request-id' => ['one', 'two', 'three'], + 'x-count' => ['42'], + 123 => ['raw numeric header name'], + 456 => ['mapped numeric header name'], + ], HttpHeaderNormalizer::normalize([ + 'Content-Type: application/json', + 'X-Request-ID' => ['one', 'two'], + 'x-request-id: three', + 'X-Count' => 42, + '123: raw numeric header name', + 456 => ['mapped numeric header name'], + ])); + } + + public function testMixedFormatsPreserveHeaderValueOrder(): void + { + $this->assertSame([ + 'x-test' => ['first', 'second', 'third', 'fourth', 'fifth'], + 'location' => ['https://example.com/a:b'], + ], HttpHeaderNormalizer::normalize([ + 'X-Test: first', + 'x-test' => ['second'], + 'X-TEST: third', + ' X-Test ' => ['fourth', 'fifth'], + "Location: https://example.com/a:b\r\n", + ])); + } + + public function testNormalizationIsIdempotentAndDoesNotModifyInput(): void + { + $headers = [ + ' X-Test ' => [7, null], + 'x-test: value', + 123 => ['numeric'], + 'X-Nested' => [['secret' => 'must-not-be-collected']], + ]; + $original = $headers; + $normalized = HttpHeaderNormalizer::normalize($headers); + + $this->assertSame($original, $headers); + $this->assertSame([ + 'x-test' => ['7', '[Filtered]', 'value'], + 123 => ['numeric'], + 'x-nested' => ['[Filtered]'], + ], $normalized); + $this->assertSame($normalized, HttpHeaderNormalizer::normalize($normalized)); + } + + public function testResourceValuesAreNotReadOrClosed(): void + { + $resource = fopen('php://temp', 'r+'); + $this->assertIsResource($resource); + + try { + fwrite($resource, 'must-not-be-collected'); + fseek($resource, 4); + + $this->assertSame(['x-stream' => ['[Filtered]']], HttpHeaderNormalizer::normalize(['X-Stream' => $resource])); + $this->assertIsResource($resource); + $this->assertSame(4, ftell($resource)); + } finally { + fclose($resource); + } + } + + public function testNormalizationDoesNotInvokeApplicationCallbacksOrRetainObjects(): void + { + $value = new class { + /** + * @var string + */ + public $privateContext = 'must-not-be-collected'; + + public function __toString(): string + { + throw new \LogicException('Must not be invoked by collection'); + } + }; + $headers = HttpHeaderNormalizer::normalize(['X-Test' => [$value], 'x-test' => $value, $value]); + $this->assertSame(['x-test' => ['[Filtered]', '[Filtered]']], $headers); + $this->assertSame('{"x-test":["[Filtered]","[Filtered]"]}', json_encode($headers)); + } + + public function testNullableAndScalarHeaderBagValuesAreNormalized(): void + { + $this->assertSame([ + 'x-null' => ['[Filtered]'], + 'x-mixed' => ['[Filtered]', 'value', '7', ''], + ], HttpHeaderNormalizer::normalize([ + 'X-Null' => null, + 'X-Mixed' => [null, 'value', 7, false], + ])); + } + + public function testEmptyAndMalformedHeadersAreIgnored(): void + { + $this->assertSame([], HttpHeaderNormalizer::normalize([ + 'Invalid', + ': empty header name', + 'X-Removed' => [], + false, + ])); + } + + public function testNormalizedHeadersCanBeCollected(): void + { + $headers = HttpHeaderNormalizer::normalize([ + 'Authorization: Bearer secret', + 'Cookie' => ['session_id=secret'], + 'X-Request-ID' => 'request-id', + ]); + $this->assertSame([ + 'http.request.header.cookie' => ['[Filtered]'], + 'http.request.header.authorization' => ['[Filtered]'], + 'http.request.header.x-request-id' => ['request-id'], + ], HttpDataCollector::collectHeaders(new DataCollectionOptions(), $headers, 'request')); + } +} diff --git a/tests/Integration/RequestIntegrationTest.php b/tests/Integration/RequestIntegrationTest.php index e6e723a59..edb23cb78 100644 --- a/tests/Integration/RequestIntegrationTest.php +++ b/tests/Integration/RequestIntegrationTest.php @@ -531,7 +531,7 @@ public static function invokeDataProvider(): iterable ->withHeader('Authorization', 'Bearer secret') ->withHeader('X-Request-Id', 'request-id'), [ - 'url' => 'http://www.example.com/foo?token=%5BFiltered%5D&page=%5BFiltered%5D', + 'url' => 'http://www.example.com/foo?token=[Filtered]&page=[Filtered]', 'method' => 'GET', 'query_string' => 'token=[Filtered]&page=[Filtered]', 'cookies' => [ @@ -553,7 +553,7 @@ public static function invokeDataProvider(): iterable 'data_collection' => [], 'max_request_body_size' => 'always', ], - (new ServerRequest('POST', 'http://www.example.com/foo?api%5Ftoken=secret&q=a%20b%26c', [], null, '1.1', ['REMOTE_ADDR' => '127.0.0.1'])) + (new ServerRequest('POST', 'http://user:password@www.example.com/foo?api%5Ftoken=secret&q=a%20b%26c', [], null, '1.1', ['REMOTE_ADDR' => '127.0.0.1'])) ->withCookieParams([ 'session_id' => 'secret', 'theme' => 'dark', @@ -570,7 +570,7 @@ public static function invokeDataProvider(): iterable ], ]), [ - 'url' => 'http://www.example.com/foo?api%5Ftoken=%5BFiltered%5D&q=a%20b%26c', + 'url' => 'http://www.example.com/foo?api%5Ftoken=[Filtered]&q=a%20b%26c', 'method' => 'POST', 'query_string' => 'api%5Ftoken=[Filtered]&q=a%20b%26c', 'env' => [ diff --git a/tests/Tracing/GuzzleTracingMiddlewareTest.php b/tests/Tracing/GuzzleTracingMiddlewareTest.php index f8c2106c2..d7971d61c 100644 --- a/tests/Tracing/GuzzleTracingMiddlewareTest.php +++ b/tests/Tracing/GuzzleTracingMiddlewareTest.php @@ -502,7 +502,7 @@ public function testTraceCollectsConfiguredOutgoingHttpData(): void $this->assertSame(0, $response->getBody()->tell()); $expectedSharedData = [ - 'url.full' => 'https://www.example.com/path?search=hello%20world&password=%5BFiltered%5D#fragment', + 'url.full' => 'https://www.example.com/path?search=hello%20world&password=[Filtered]#fragment', 'http.query' => 'search=hello%20world&password=[Filtered]', ]; $expectedSpanData = [ @@ -540,6 +540,43 @@ public function testTraceCollectsConfiguredOutgoingHttpData(): void $this->assertStringNotContainsString('response-secret', json_encode($spanData)); } + public function testTracePreservesExplicitSpanData(): void + { + $client = $this->createMock(ClientInterface::class); + $client->method('getOptions')->willReturn(new Options([ + 'traces_sample_rate' => 1, + 'data_collection' => [], + ])); + $hub = new Hub($client); + $transaction = $hub->startTransaction(new TransactionContext()); + $hub->setSpan($transaction); + + $middleware = GuzzleTracingMiddleware::trace($hub); + $function = $middleware(function () use ($hub): PromiseInterface { + $span = $hub->getSpan(); + $this->assertNotNull($span); + $span->setData([ + 'http.query' => 'explicit', + 'http.response.header.x-test' => ['explicit'], + 'http.response.body.data' => ['password' => 'explicit'], + ]); + + return new FulfilledPromise(new Response(200, [ + 'Content-Type' => 'application/json', + 'X-Test' => 'automatic', + ], '{"name":"automatic"}')); + }); + /** @var PromiseInterface $promise */ + $promise = $function(new Request('GET', 'https://www.example.com/?token=secret'), []); + $promise->wait(); + + $data = $this->getHttpSpan($transaction)->getData(); + $this->assertSame('explicit', $data['http.query']); + $this->assertSame(['explicit'], $data['http.response.header.x-test']); + $this->assertSame(['password' => 'explicit'], $data['http.response.body.data']); + $this->assertSame(['application/json'], $data['http.response.header.content-type']); + } + public function testTraceDoesNotConsumeNonSeekableBodies(): void { $sdkOptions = new Options([ From dd602cc765147fdc76948534d1839a05ddc4d5fd Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 7 Sep 2026 20:18:29 +0200 Subject: [PATCH 2/4] add body collector --- src/DataCollection/HttpBodyCollector.php | 127 ++++++++++++ src/DataCollection/KeyValueDataFilter.php | 12 +- src/Tracing/GuzzleTracingMiddleware.php | 89 +++------ .../DataCollection/HttpBodyCollectorTest.php | 184 ++++++++++++++++++ .../DataCollection/KeyValueDataFilterTest.php | 29 ++- .../RequestDataCollectorTest.php | 7 + tests/Tracing/GuzzleTracingMiddlewareTest.php | 34 ++-- 7 files changed, 400 insertions(+), 82 deletions(-) create mode 100644 src/DataCollection/HttpBodyCollector.php create mode 100644 tests/DataCollection/HttpBodyCollectorTest.php diff --git a/src/DataCollection/HttpBodyCollector.php b/src/DataCollection/HttpBodyCollector.php new file mode 100644 index 000000000..0d190da4c --- /dev/null +++ b/src/DataCollection/HttpBodyCollector.php @@ -0,0 +1,127 @@ + 0, + 'never' => 0, + 'small' => 10 ** 3, + 'medium' => 10 ** 4, + 'always' => self::MAX_BODY_LENGTH, + ]; + + private function __construct() + { + } + + /** + * @param 'incomingRequest'|'outgoingRequest'|'incomingResponse'|'outgoingResponse' $bodyType + */ + public static function getMaxBodyLength(Options $options, string $bodyType): int + { + $dataCollection = $options->getDataCollection(); + if ($dataCollection === null || !\in_array($bodyType, $dataCollection->getHttpBodies(), true)) { + return 0; + } + + return $bodyType === 'incomingRequest' || $bodyType === 'outgoingRequest' + ? self::MAX_REQUEST_BODY_SIZE_TO_LENGTH[$options->getMaxRequestBodySize()] + : self::MAX_BODY_LENGTH; + } + + public static function isSupportedContentType(string $contentType): bool + { + return self::getBodyFormat($contentType) !== null; + } + + /** + * @return array|null Null means the body is not structured JSON/form data + */ + public static function parse(string $body, string $contentType): ?array + { + $format = self::getBodyFormat($contentType); + if ($format === null) { + return null; + } + + try { + /** @mago-ignore analysis:mixed-assignment */ + $parsedBody = $format === 'form' ? Query::parse($body) : JSON::decode($body); + } catch (JsonException $exception) { + return null; + } + + return \is_array($parsedBody) ? $parsedBody : null; + } + + /** + * @param array $body + * + * @return array|null Null means omitted + */ + public static function collect(array $body): ?array + { + $body = self::normalizeArray($body, 0); + + return $body === null ? null : KeyValueDataFilter::filterHttpBodyData($body); + } + + /** + * @return 'json'|'form'|null + */ + private static function getBodyFormat(string $contentType): ?string + { + $mediaType = strtolower(trim(explode(';', $contentType, 2)[0])); + if ($mediaType === 'application/json' || substr($mediaType, -5) === '+json') { + return 'json'; + } + + return $mediaType === 'application/x-www-form-urlencoded' ? 'form' : null; + } + + /** + * @param array $body + * + * @return array|null Null means normalization failed + */ + private static function normalizeArray(array $body, int $depth): ?array + { + if ($depth >= 32) { + return null; + } + + $normalized = []; + /** @mago-ignore analysis:mixed-assignment */ + foreach ($body as $key => $value) { + if (\is_array($value)) { + $value = self::normalizeArray($value, $depth + 1); + if ($value === null) { + return null; + } + } elseif ($value !== null && !\is_scalar($value)) { + $value = KeyValueDataFilter::FILTERED_VALUE; + } + + $normalized[$key] = $value; + } + + return $normalized; + } +} diff --git a/src/DataCollection/KeyValueDataFilter.php b/src/DataCollection/KeyValueDataFilter.php index e5d221837..133c78dca 100644 --- a/src/DataCollection/KeyValueDataFilter.php +++ b/src/DataCollection/KeyValueDataFilter.php @@ -119,7 +119,7 @@ public static function filterKeyValueData(array $data, array $behavior): ?array } /** - * Filters structured HTTP body data while replacing unkeyed top-level values. + * Filters HTTP body fields by key name while retaining scalar list values. * * @param array $data * @@ -135,9 +135,13 @@ public static function filterHttpBodyData(array $data): array /** @mago-ignore analysis:mixed-assignment */ foreach ($data as $value) { - $filtered[] = \is_array($value) - ? self::filterHttpBodyData($value) - : self::FILTERED_VALUE; + if (\is_array($value)) { + $value = self::filterHttpBodyData($value); + } elseif ($value !== null && !\is_scalar($value)) { + $value = self::FILTERED_VALUE; + } + + $filtered[] = $value; } return $filtered; diff --git a/src/Tracing/GuzzleTracingMiddleware.php b/src/Tracing/GuzzleTracingMiddleware.php index 2b01a537a..72deb3ecf 100644 --- a/src/Tracing/GuzzleTracingMiddleware.php +++ b/src/Tracing/GuzzleTracingMiddleware.php @@ -5,20 +5,18 @@ 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\DataCollection\DataCollectionOptions; +use Sentry\DataCollection\HttpBodyCollector; use Sentry\DataCollection\HttpDataCollector; 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; @@ -28,17 +26,6 @@ */ 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 { @@ -86,8 +73,7 @@ public static function trace(?HubInterface $hub = null): \Closure $spanData = array_merge( $spanData, self::collectRequestSpanData( - $dataCollection, - $sdkOptions->getMaxRequestBodySize(), + $sdkOptions, $request, $requestBody ) @@ -117,7 +103,7 @@ public static function trace(?HubInterface $hub = null): \Closure } } - $handlerPromiseCallback = static function ($responseOrException) use ($hub, $spanAndBreadcrumbData, $spanData, $childSpan, $parentSpan, $collectedUrl, $dataCollection) { + $handlerPromiseCallback = static function ($responseOrException) use ($hub, $spanAndBreadcrumbData, $spanData, $childSpan, $parentSpan, $collectedUrl, $dataCollection, $sdkOptions) { 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 @@ -155,7 +141,7 @@ public static function trace(?HubInterface $hub = null): \Closure $spanData = array_merge( $spanData, $spanAndBreadcrumbData, - self::collectResponseSpanData($dataCollection, $response) + self::collectResponseSpanData($sdkOptions, $response) ); $childSpan->setStatus(SpanStatus::createFromHttpStatusCode($response->getStatusCode())); if ($dataCollection === null) { @@ -191,23 +177,21 @@ public static function trace(?HubInterface $hub = null): \Closure } /** - * @param 'none'|'never'|'small'|'medium'|'always' $maxRequestBodySize - * * @return array */ - private static function collectRequestSpanData( - DataCollectionOptions $dataCollection, - string $maxRequestBodySize, - RequestInterface $request, - StreamInterface $body - ): array { - $data = HttpDataCollector::collectHeaders($dataCollection, $request->getHeaders(), 'request'); + private static function collectRequestSpanData(Options $options, RequestInterface $request, StreamInterface $body): array + { + $dataCollection = $options->getDataCollection(); + if ($dataCollection === null) { + return []; + } - if (!\in_array('outgoingRequest', $dataCollection->getHttpBodies(), true)) { + $data = HttpDataCollector::collectHeaders($dataCollection, $request->getHeaders(), 'request'); + $maxBodyLength = HttpBodyCollector::getMaxBodyLength($options, 'outgoingRequest'); + if ($maxBodyLength === 0) { return $data; } - $maxBodyLength = self::MAX_REQUEST_BODY_SIZE_TO_LENGTH[$maxRequestBodySize]; $collectedBody = self::collectBody($body, $request->getHeaderLine('Content-Type'), $maxBodyLength); if ($collectedBody !== null) { @@ -220,23 +204,25 @@ private static function collectRequestSpanData( /** * @return array */ - private static function collectResponseSpanData(?DataCollectionOptions $dataCollection, ResponseInterface $response): array + private static function collectResponseSpanData(?Options $options, ResponseInterface $response): array { + if ($options === null) { + return []; + } + + $dataCollection = $options->getDataCollection(); if ($dataCollection === null) { return []; } $data = HttpDataCollector::collectHeaders($dataCollection, $response->getHeaders(), 'response'); - if (!\in_array('incomingResponse', $dataCollection->getHttpBodies(), true)) { + $maxBodyLength = HttpBodyCollector::getMaxBodyLength($options, 'incomingResponse'); + if ($maxBodyLength === 0) { return $data; } - $collectedBody = self::collectBody( - $response->getBody(), - $response->getHeaderLine('Content-Type'), - self::HTTP_BODY_MAX_CONTENT_LENGTH - ); + $collectedBody = self::collectBody($response->getBody(), $response->getHeaderLine('Content-Type'), $maxBodyLength); if ($collectedBody !== null) { $data['http.response.body.data'] = $collectedBody; @@ -259,40 +245,19 @@ private static function collectBody(StreamInterface $body, string $contentType, 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) { + if (!HttpBodyCollector::isSupportedContentType($contentType)) { 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) { + $contents = self::readBody($body, $maxBodyLength); + if ($contents === 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; - } + $parsedBody = HttpBodyCollector::parse($contents, $contentType); - return KeyValueDataFilter::filterHttpBodyData($decodedBody); + return $parsedBody === null ? KeyValueDataFilter::FILTERED_VALUE : HttpBodyCollector::collect($parsedBody); } private static function readBody(StreamInterface $body, int $maxBodyLength): ?string diff --git a/tests/DataCollection/HttpBodyCollectorTest.php b/tests/DataCollection/HttpBodyCollectorTest.php new file mode 100644 index 000000000..3f3f337fb --- /dev/null +++ b/tests/DataCollection/HttpBodyCollectorTest.php @@ -0,0 +1,184 @@ +assertSame(0, HttpBodyCollector::getMaxBodyLength(new Options(), $type)); + $this->assertSame(0, HttpBodyCollector::getMaxBodyLength(new Options(['data_collection' => ['http_bodies' => []]]), $type)); + foreach (['never' => 0, 'none' => 0, 'small' => 1000, 'medium' => 10000, 'always' => 100000] as $size => $limit) { + $options = new Options(['data_collection' => [], 'max_request_body_size' => $size]); + $this->assertSame(substr($type, -7) === 'Request' ? $limit : 100000, HttpBodyCollector::getMaxBodyLength($options, $type)); + } + } + + $options = new Options(['data_collection' => ['http_bodies' => ['outgoingResponse']]]); + $this->assertSame(0, HttpBodyCollector::getMaxBodyLength($options, 'incomingResponse')); + $this->assertSame(100000, HttpBodyCollector::getMaxBodyLength($options, 'outgoingResponse')); + } + + public function testContentTypeSupport(): void + { + foreach ([ + 'application/json' => true, + ' Application/Problem+JSON ; charset=UTF-8' => true, + ' APPLICATION/X-WWW-FORM-URLENCODED ; charset=UTF-8' => true, + '' => false, + 'text/plain' => false, + 'application/jsonp' => false, + 'application/json+unknown' => false, + 'multipart/form-data; boundary=123' => false, + ] as $contentType => $expected) { + $this->assertSame($expected, HttpBodyCollector::isSupportedContentType($contentType)); + } + } + + /** + * @dataProvider parseProvider + * + * @param array|null $expected + */ + public function testParse(string $body, string $contentType, ?array $expected): void + { + $this->assertSame($expected, HttpBodyCollector::parse($body, $contentType)); + } + + public function parseProvider(): \Generator + { + yield 'JSON' => ['{"name":"Alice","password":"secret"}', 'application/json', ['name' => 'Alice', 'password' => 'secret']]; + yield 'JSON suffix' => ['{"token":"secret"}', ' Application/Problem+JSON ; charset=UTF-8', ['token' => 'secret']]; + yield 'form' => ['name=Alice&password=secret', 'application/x-www-form-urlencoded; charset=UTF-8', ['name' => 'Alice', 'password' => 'secret']]; + yield 'uppercase form' => ['password=secret', ' APPLICATION/X-WWW-FORM-URLENCODED ; charset=UTF-8', ['password' => 'secret']]; + yield 'repeated form keys' => ['name=Alice&name=Bob', 'application/x-www-form-urlencoded', ['name' => ['Alice', 'Bob']]]; + yield 'JSON list' => ['["secret",{"name":"Alice"}]', 'application/json', ['secret', ['name' => 'Alice']]]; + yield 'invalid JSON' => ['{invalid', 'application/json', null]; + yield 'scalar JSON' => ['42', 'application/json', null]; + yield 'boolean JSON' => ['false', 'application/json', null]; + yield 'string JSON' => ['"secret"', 'application/json', null]; + yield 'null JSON' => ['null', 'application/json', null]; + yield 'unsupported type' => ['raw secret', 'text/plain', null]; + yield 'multipart' => ['raw secret', 'multipart/form-data; boundary=123', null]; + yield 'invalid UTF-8' => ["{\"value\":\"\xff\"}", 'application/json', null]; + yield 'empty string' => ['', 'application/json', null]; + yield 'empty JSON object' => ['{}', 'application/json', []]; + yield 'empty JSON list' => ['[]', 'application/json', []]; + } + + /** + * @dataProvider bodyProvider + * + * @param array $body + * @param array $expected + */ + public function testCollect(array $body, array $expected): void + { + $this->assertSame($expected, HttpBodyCollector::collect($body)); + } + + public function bodyProvider(): \Generator + { + yield 'parsed data' => [['profile' => ['name' => 'Alice', 'password' => 'secret']], ['profile' => ['name' => 'Alice', 'password' => '[Filtered]']]]; + yield 'nested object' => [['profile' => (object) ['password' => 'secret']], ['profile' => '[Filtered]']]; + yield 'unkeyed data' => [['secret', ['name' => 'Alice']], ['secret', ['name' => 'Alice']]]; + yield 'scalar list' => [['secret', 'foo', false, null], ['secret', 'foo', false, null]]; + yield 'sensitive parent' => [['token' => ['foo']], ['token' => '[Filtered]']]; + yield 'empty array' => [[], []]; + } + + public function testParsedBodiesAreCollected(): void + { + $body = HttpBodyCollector::parse('["secret",{"password":"value","name":"Alice"}]', 'application/json'); + $this->assertNotNull($body); + $this->assertSame(['secret', ['password' => '[Filtered]', 'name' => 'Alice']], HttpBodyCollector::collect($body)); + } + + public function testNestedObjectsAreFilteredWithoutCallbacksOrInputChanges(): void + { + $object = new class implements \JsonSerializable { + public function jsonSerialize(): array + { + throw new \LogicException('Must not serialize application objects'); + } + + public function __toString(): string + { + throw new \LogicException('Must not stringify application objects'); + } + }; + $plain = (object) ['password' => 'secret']; + $body = ['object' => $object, 'profile' => $plain]; + $this->assertSame(['object' => '[Filtered]', 'profile' => '[Filtered]'], HttpBodyCollector::collect($body)); + $this->assertSame(['object' => $object, 'profile' => $plain], $body); + $this->assertSame('secret', $plain->password); + $subclass = new class extends \stdClass implements \IteratorAggregate { + public function getIterator(): \Traversable + { + throw new \LogicException('Must not iterate application objects'); + } + }; + $this->assertSame(['object' => '[Filtered]'], HttpBodyCollector::collect(['object' => $subclass])); + } + + public function testResourcesAreNotReadOrClosed(): void + { + $resource = fopen('php://temp', 'r+'); + $this->assertIsResource($resource); + try { + fwrite($resource, 'prefix:secret'); + fseek($resource, 7); + $this->assertSame(['file' => '[Filtered]'], HttpBodyCollector::collect(['file' => $resource])); + $this->assertSame(7, ftell($resource)); + $this->assertSame('secret', stream_get_contents($resource)); + } finally { + fclose($resource); + } + $this->assertSame(['file' => '[Filtered]'], HttpBodyCollector::collect(['file' => $resource])); + } + + public function testNormalizationDepthIsBounded(): void + { + $body = ['value' => null]; + for ($i = 0; $i < 31; ++$i) { + $body = ['child' => $body]; + } + + $this->assertSame($body, HttpBodyCollector::collect($body)); + $this->assertNull(HttpBodyCollector::collect(['child' => $body])); + + $parsed = HttpBodyCollector::parse(JSON::encode($body), 'application/json'); + $this->assertNotNull($parsed); + $this->assertSame($body, HttpBodyCollector::collect($parsed)); + $parsed = HttpBodyCollector::parse(JSON::encode(['child' => $body]), 'application/json'); + $this->assertNotNull($parsed); + $this->assertNull(HttpBodyCollector::collect($parsed)); + } + + public function testNormalizationPreservesReferencedInput(): void + { + $object = (object) ['value' => 'original']; + $nested = ['value' => &$object]; + $body = ['nested' => &$nested]; + $this->assertSame(['nested' => ['value' => '[Filtered]']], HttpBodyCollector::collect($body)); + $this->assertSame($object, $nested['value']); + $this->assertSame($object, $body['nested']['value']); + $this->assertSame('original', $object->value); + } + + public function testRecursiveArraysAreOmitted(): void + { + $array = []; + $array['self'] = &$array; + $this->assertNull(HttpBodyCollector::collect($array)); + } +} diff --git a/tests/DataCollection/KeyValueDataFilterTest.php b/tests/DataCollection/KeyValueDataFilterTest.php index ea56ad62a..d588a27ea 100644 --- a/tests/DataCollection/KeyValueDataFilterTest.php +++ b/tests/DataCollection/KeyValueDataFilterTest.php @@ -96,14 +96,14 @@ public function testFilterKeyValueDataFiltersNestedData(): void ], $filtered); } - public function testFilterHttpBodyDataFiltersSensitiveAndUnkeyedValues(): void + public function testFilterHttpBodyDataFiltersSensitiveKeysWithinLists(): void { $this->assertSame([ [ 'password' => '[Filtered]', 'name' => 'alice', ], - '[Filtered]', + 'unkeyed secret', ], KeyValueDataFilter::filterHttpBodyData([ [ 'password' => 'secret', @@ -113,6 +113,31 @@ public function testFilterHttpBodyDataFiltersSensitiveAndUnkeyedValues(): void ])); } + public function testFilterHttpBodyDataPreservesScalarListValues(): void + { + $data = ['secret', 'foo', false, null, 123, ['token', 'password']]; + + $this->assertSame($data, KeyValueDataFilter::filterHttpBodyData($data)); + } + + public function testFilterHttpBodyDataKeepsKeysAndFiltersSensitiveParentValues(): void + { + $this->assertSame([ + 2 => 'secret', + 'items' => ['secret', ['PaSsWoRd' => '[Filtered]', 'name' => 'secret']], + 'token' => '[Filtered]', + ], KeyValueDataFilter::filterHttpBodyData([ + 2 => 'secret', + 'items' => ['secret', ['PaSsWoRd' => 'value', 'name' => 'secret']], + 'token' => ['foo'], + ])); + } + + public function testFilterHttpBodyDataStillFiltersOpaqueListValues(): void + { + $this->assertSame(['[Filtered]'], KeyValueDataFilter::filterHttpBodyData([new \stdClass()])); + } + public function testFilterHeadersReturnsNullWhenCollectionIsOff(): void { $behavior = ['mode' => 'off', 'terms' => ['x-request-id']]; diff --git a/tests/DataCollection/RequestDataCollectorTest.php b/tests/DataCollection/RequestDataCollectorTest.php index d17ec4bc2..4a78d1eba 100644 --- a/tests/DataCollection/RequestDataCollectorTest.php +++ b/tests/DataCollection/RequestDataCollectorTest.php @@ -204,6 +204,13 @@ public function testCollectRequestBodyFiltersStructuredSensitiveDataRecursively( ])); } + public function testCollectRequestBodyPreservesScalarLists(): void + { + $collector = $this->collector(['http_bodies' => ['incomingRequest']]); + + $this->assertSame(['secret', 'foo'], $collector->collectRequestBody(['secret', 'foo'])); + } + public function testCollectRequestBodyFiltersRawData(): void { $collector = $this->collector(['http_bodies' => ['incomingRequest']]); diff --git a/tests/Tracing/GuzzleTracingMiddlewareTest.php b/tests/Tracing/GuzzleTracingMiddlewareTest.php index d7971d61c..1b4aab7ee 100644 --- a/tests/Tracing/GuzzleTracingMiddlewareTest.php +++ b/tests/Tracing/GuzzleTracingMiddlewareTest.php @@ -491,7 +491,7 @@ public function testTraceCollectsConfiguredOutgoingHttpData(): void 'Authorization' => 'Bearer request-secret', 'Cookie' => 'session_id=request-secret; theme=dark', ], - '[{"password":"request-secret","name":"Alice"},"unkeyed-request-secret"]' + '[{"password":"request-secret","name":"Alice"},"unkeyed-secret"]' ); /** @var PromiseInterface $promise */ @@ -514,7 +514,7 @@ public function testTraceCollectsConfiguredOutgoingHttpData(): void 'password' => '[Filtered]', 'name' => 'Alice', ], - '[Filtered]', + 'unkeyed-secret', ], 'http.response.header.content-type' => ['application/x-www-form-urlencoded'], 'http.response.header.x-response-id' => ['response-123'], @@ -662,7 +662,7 @@ public function testTraceSkipsBodiesLargerThanTheirLimits(): void /** * @dataProvider httpBodySafetyLimitDataProvider */ - public function testTraceAppliesHttpBodySafetyLimit(int $bodySize, bool $shouldCollect): void + public function testTraceAppliesHttpBodySafetyLimit(int $bodySize, bool $shouldCollect, ?int $reportedSize): void { $client = $this->createMock(ClientInterface::class); $client->expects($this->atLeastOnce()) @@ -679,18 +679,20 @@ public function testTraceAppliesHttpBodySafetyLimit(int $bodySize, bool $shouldC $transaction = $hub->startTransaction(new TransactionContext()); $hub->setSpan($transaction); - $rawBody = str_repeat('a', $bodySize); + $expectedBody = ['value' => str_repeat('a', $bodySize - 12)]; + $rawBody = '{"value":"' . $expectedBody['value'] . '"}'; $requestBody = FnStream::decorate(Utils::streamFor($rawBody), [ - 'getSize' => static function (): ?int { - return null; + 'getSize' => static function () use ($reportedSize): ?int { + return $reportedSize; }, ]); $responseBody = FnStream::decorate(Utils::streamFor($rawBody), [ - 'getSize' => static function (): ?int { - return null; + 'getSize' => static function () use ($reportedSize): ?int { + return $reportedSize; }, ]); - $response = new Response(200, ['Content-Type' => 'application/json'], $responseBody); + $headers = ['Content-Type' => 'application/json', 'Content-Length' => '1']; + $response = new Response(200, $headers, $responseBody); $middleware = GuzzleTracingMiddleware::trace($hub); $function = $middleware(static function () use ($response): PromiseInterface { return new FulfilledPromise($response); @@ -700,7 +702,7 @@ public function testTraceAppliesHttpBodySafetyLimit(int $bodySize, bool $shouldC $promise = $function(new Request( 'POST', 'https://www.example.com', - ['Content-Type' => 'application/json'], + $headers, $requestBody ), []); $promise->wait(); @@ -710,8 +712,8 @@ public function testTraceAppliesHttpBodySafetyLimit(int $bodySize, bool $shouldC $spanData = $this->getHttpSpan($transaction)->getData(); if ($shouldCollect) { - $this->assertSame('[Filtered]', $spanData['http.request.body.data']); - $this->assertSame('[Filtered]', $spanData['http.response.body.data']); + $this->assertSame($expectedBody, $spanData['http.request.body.data']); + $this->assertSame($expectedBody, $spanData['http.response.body.data']); } else { $this->assertArrayNotHasKey('http.request.body.data', $spanData); $this->assertArrayNotHasKey('http.response.body.data', $spanData); @@ -720,8 +722,12 @@ public function testTraceAppliesHttpBodySafetyLimit(int $bodySize, bool $shouldC public static function httpBodySafetyLimitDataProvider(): iterable { - yield 'at 100 KB safety limit' => [100000, true]; - yield 'over 100 KB safety limit' => [100001, false]; + yield 'unknown size at limit' => [100000, true, null]; + yield 'unknown size over limit' => [100001, false, null]; + yield 'reported size at limit' => [100000, true, 100000]; + yield 'reported size over limit' => [100001, false, 100001]; + yield 'underreported size at limit' => [100000, true, 1]; + yield 'underreported size over limit' => [100001, false, 1]; } public function testTraceRespectsDisabledOutgoingHttpDataCollection(): void From e319058b425d939a9c318052b8494f7ef6cfebb3 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Wed, 9 Sep 2026 14:04:48 +0200 Subject: [PATCH 3/4] wip --- src/DataCollection/DataCollectionOptions.php | 17 +- src/DataCollection/HttpBodyCollector.php | 127 ---------- src/DataCollection/HttpDataCollector.php | 217 +++++++++++++++-- src/DataCollection/HttpHeaderNormalizer.php | 7 +- src/DataCollection/KeyValueDataFilter.php | 48 ++-- src/DataCollection/RequestDataCollector.php | 89 +++---- src/Integration/RequestIntegration.php | 55 +++-- src/Tracing/GuzzleTracingMiddleware.php | 149 ++---------- .../DataCollectionOptionsTest.php | 60 +++++ .../DataCollection/HttpBodyCollectorTest.php | 184 --------------- .../DataCollection/HttpDataCollectorTest.php | 166 ++++++++++++- .../HttpHeaderNormalizerTest.php | 5 +- .../DataCollection/KeyValueDataFilterTest.php | 90 +++----- .../RequestDataCollectorTest.php | 160 +++++++------ tests/Integration/RequestIntegrationTest.php | 80 ++++++- tests/Tracing/GuzzleTracingMiddlewareTest.php | 218 ++++-------------- 16 files changed, 798 insertions(+), 874 deletions(-) delete mode 100644 src/DataCollection/HttpBodyCollector.php delete mode 100644 tests/DataCollection/HttpBodyCollectorTest.php diff --git a/src/DataCollection/DataCollectionOptions.php b/src/DataCollection/DataCollectionOptions.php index 62f60f4b3..a3d25fd92 100644 --- a/src/DataCollection/DataCollectionOptions.php +++ b/src/DataCollection/DataCollectionOptions.php @@ -4,7 +4,9 @@ namespace Sentry\DataCollection; +use Sentry\Options; use Sentry\OptionsResolver; +use Sentry\State\HubInterface; /** * @phpstan-type KeyValueCollectionBehavior array{mode: 'off'|'denyList'|'allowList', terms: string[]} @@ -43,9 +45,6 @@ final class DataCollectionOptions implements \ArrayAccess 'terms' => [], ]; - /** - * @internal - */ public const HTTP_BODY_TYPES = [ 'incomingRequest', 'outgoingRequest', @@ -97,6 +96,18 @@ public function __construct(array $options = []) $this->options = $resolvedOptions; } + public static function fromHub(HubInterface $hub): ?self + { + $client = $hub->getClient(); + + return self::fromOptions($client === null ? null : $client->getOptions()); + } + + public static function fromOptions(?Options $options): ?self + { + return $options === null ? null : $options->getDataCollection(); + } + public function shouldCollectUserInfo(): bool { return $this->options['user_info']; diff --git a/src/DataCollection/HttpBodyCollector.php b/src/DataCollection/HttpBodyCollector.php deleted file mode 100644 index 0d190da4c..000000000 --- a/src/DataCollection/HttpBodyCollector.php +++ /dev/null @@ -1,127 +0,0 @@ - 0, - 'never' => 0, - 'small' => 10 ** 3, - 'medium' => 10 ** 4, - 'always' => self::MAX_BODY_LENGTH, - ]; - - private function __construct() - { - } - - /** - * @param 'incomingRequest'|'outgoingRequest'|'incomingResponse'|'outgoingResponse' $bodyType - */ - public static function getMaxBodyLength(Options $options, string $bodyType): int - { - $dataCollection = $options->getDataCollection(); - if ($dataCollection === null || !\in_array($bodyType, $dataCollection->getHttpBodies(), true)) { - return 0; - } - - return $bodyType === 'incomingRequest' || $bodyType === 'outgoingRequest' - ? self::MAX_REQUEST_BODY_SIZE_TO_LENGTH[$options->getMaxRequestBodySize()] - : self::MAX_BODY_LENGTH; - } - - public static function isSupportedContentType(string $contentType): bool - { - return self::getBodyFormat($contentType) !== null; - } - - /** - * @return array|null Null means the body is not structured JSON/form data - */ - public static function parse(string $body, string $contentType): ?array - { - $format = self::getBodyFormat($contentType); - if ($format === null) { - return null; - } - - try { - /** @mago-ignore analysis:mixed-assignment */ - $parsedBody = $format === 'form' ? Query::parse($body) : JSON::decode($body); - } catch (JsonException $exception) { - return null; - } - - return \is_array($parsedBody) ? $parsedBody : null; - } - - /** - * @param array $body - * - * @return array|null Null means omitted - */ - public static function collect(array $body): ?array - { - $body = self::normalizeArray($body, 0); - - return $body === null ? null : KeyValueDataFilter::filterHttpBodyData($body); - } - - /** - * @return 'json'|'form'|null - */ - private static function getBodyFormat(string $contentType): ?string - { - $mediaType = strtolower(trim(explode(';', $contentType, 2)[0])); - if ($mediaType === 'application/json' || substr($mediaType, -5) === '+json') { - return 'json'; - } - - return $mediaType === 'application/x-www-form-urlencoded' ? 'form' : null; - } - - /** - * @param array $body - * - * @return array|null Null means normalization failed - */ - private static function normalizeArray(array $body, int $depth): ?array - { - if ($depth >= 32) { - return null; - } - - $normalized = []; - /** @mago-ignore analysis:mixed-assignment */ - foreach ($body as $key => $value) { - if (\is_array($value)) { - $value = self::normalizeArray($value, $depth + 1); - if ($value === null) { - return null; - } - } elseif ($value !== null && !\is_scalar($value)) { - $value = KeyValueDataFilter::FILTERED_VALUE; - } - - $normalized[$key] = $value; - } - - return $normalized; - } -} diff --git a/src/DataCollection/HttpDataCollector.php b/src/DataCollection/HttpDataCollector.php index f6f77e0c5..85b1602fe 100644 --- a/src/DataCollection/HttpDataCollector.php +++ b/src/DataCollection/HttpDataCollector.php @@ -10,8 +10,6 @@ /** * Collects transport-independent HTTP data. Integrations provide normalized * inputs without consuming streams or invoking application callbacks. - * - * @internal */ final class HttpDataCollector { @@ -19,6 +17,18 @@ private function __construct() { } + /** + * Collects the HTTP query attribute for spans and breadcrumbs. + * + * @return array + */ + public static function collectQueryData(?DataCollectionOptions $dataCollection, string $queryString): array + { + $queryString = self::collectQueryString($dataCollection, $queryString); + + return $queryString === null ? [] : ['http.query' => $queryString]; + } + public static function collectQueryString(?DataCollectionOptions $dataCollection, string $queryString): ?string { if ($queryString === '') { @@ -30,10 +40,10 @@ public static function collectQueryString(?DataCollectionOptions $dataCollection : KeyValueDataFilter::filterQueryString($queryString, $dataCollection->getUrlQueryParams()); } - public static function collectUrl(?DataCollectionOptions $dataCollection, string $url): string + public static function collectUrl(?DataCollectionOptions $dataCollection, string $url, ?string $legacyUrl = null): string { if ($dataCollection === null) { - return $url; + return $legacyUrl ?? $url; } $uri = new Uri($url); @@ -52,42 +62,207 @@ public static function collectUrl(?DataCollectionOptions $dataCollection, string } /** + * Collects HTTP request headers and cookies. + * + * @param array $headers Normalized lowercase header names + * @param array|null $cookies Parsed cookies + * + * @return array + */ + public static function collectRequestData(?DataCollectionOptions $dataCollection, array $headers, ?array $cookies = null): array + { + if ($dataCollection === null) { + return []; + } + + $data = self::collectRequestHeaders($dataCollection, $headers); + if ($dataCollection->getCookies()['mode'] !== 'off') { + $cookies = $cookies ?? self::parseRequestCookies($headers['cookie'] ?? []); + $data = array_merge($data, self::collectRequestCookies($dataCollection, $cookies)); + } + + return $data; + } + + /** + * Collects HTTP response attributes from normalized inputs. + * + * @param array $headers Normalized lowercase header names + * @param iterable|null $cookies Parsed cookie name/value pairs + * + * @return array + */ + public static function collectResponseData(?DataCollectionOptions $dataCollection, array $headers, ?iterable $cookies = null): array + { + if ($dataCollection === null) { + return []; + } + + $data = self::collectResponseHeaders($dataCollection, $headers); + if ($dataCollection->getCookies()['mode'] !== 'off') { + $cookies = $cookies === null + ? self::parseResponseCookies($headers['set-cookie'] ?? []) + : self::groupCookieValues($cookies); + $data = array_merge($data, self::collectResponseCookies($dataCollection, $cookies)); + } + + return $data; + } + + /** + * @param array $headers Normalized lowercase header names + * + * @return array + */ + public static function collectRequestHeaders(DataCollectionOptions $dataCollection, array $headers): array + { + return self::collectHeaders($dataCollection, $headers, 'request'); + } + + /** + * @param array $headers Normalized lowercase header names + * + * @return array + */ + public static function collectResponseHeaders(DataCollectionOptions $dataCollection, array $headers): array + { + return self::collectHeaders($dataCollection, $headers, 'response'); + } + + /** + * Collects regular headers, excluding Cookie and Set-Cookie. + * * @param array $headers * @param 'request'|'response' $direction * * @return array */ - public static function collectHeaders(DataCollectionOptions $dataCollection, array $headers, string $direction): 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); + $filteredHeaders = KeyValueDataFilter::filterHeaders($headers, $headerBehavior); + foreach ($filteredHeaders ?? [] as $name => $values) { + if ($values !== []) { + $attributes[$prefix . $name] = $values; + } + } - if ($name === 'cookie' || $name === 'set-cookie') { - if ($cookieBehavior['mode'] !== 'off' && $values !== []) { - // Raw cookie headers cannot be filtered by individual cookie name. - $attributes[$prefix . $name] = array_fill(0, \count($values), KeyValueDataFilter::FILTERED_VALUE); - } + return $attributes; + } - continue; - } + /** + * @param array $cookies + * + * @return array + */ + public static function collectRequestCookies(DataCollectionOptions $dataCollection, array $cookies): array + { + return self::collectCookies($dataCollection, $cookies, 'request'); + } - $regularHeaders[$name] = $values; - } + /** + * @param array $cookies + * + * @return array + */ + public static function collectResponseCookies(DataCollectionOptions $dataCollection, array $cookies): array + { + return self::collectCookies($dataCollection, $cookies, 'response'); + } - $filteredHeaders = KeyValueDataFilter::filterHeaders($regularHeaders, $headerBehavior); - foreach ($filteredHeaders ?? [] as $name => $values) { - $attributes[$prefix . $name] = $values; + /** + * Collects parsed cookies by name, independently of regular headers. + * + * @param array $cookies + * @param 'request'|'response' $direction + * + * @return array + */ + private static function collectCookies(DataCollectionOptions $dataCollection, array $cookies, string $direction): array + { + $filtered = KeyValueDataFilter::filterCookies($cookies, $dataCollection->getCookies()); + $prefix = $direction === 'request' ? 'http.request.header.cookie.' : 'http.response.header.set_cookie.'; + $attributes = []; + /** @mago-ignore analysis:mixed-assignment */ + foreach ($filtered ?? [] as $name => $value) { + $attributes[$prefix . $name] = $value; } return $attributes; } + /** + * @param string[] $headers Cookie header values + * + * @return array + */ + public static function parseRequestCookies(array $headers): array + { + return self::parseCookies($headers, false); + } + + /** + * @param string[] $headers Set-Cookie header values + * + * @return array + */ + public static function parseResponseCookies(array $headers): array + { + return self::parseCookies($headers, true); + } + + /** + * @param string[] $headers + * + * @return array + */ + private static function parseCookies(array $headers, bool $response): array + { + $pairs = []; + foreach ($headers as $header) { + $parts = $response ? [explode(';', $header, 2)[0]] : explode(';', $header); + foreach ($parts as $part) { + $pair = explode('=', $part, 2); + if (\count($pair) !== 2 || trim($pair[0]) === '') { + continue; + } + $pairs[] = [trim($pair[0]), trim($pair[1])]; + } + } + + return self::groupCookieValues($pairs); + } + + /** + * Groups framework cookie name/value pairs without serializing cookie objects. + * + * @template T of string|null + * + * @param iterable $cookies + * + * @return array + */ + public static function groupCookieValues(iterable $cookies): array + { + /** @var array $grouped */ + $grouped = []; + foreach ($cookies as [$name, $value]) { + if (\array_key_exists($name, $grouped)) { + $previous = $grouped[$name]; + $values = \is_array($previous) ? $previous : [$previous]; + $values[] = $value; + $grouped[$name] = $values; + } else { + $grouped[$name] = $value; + } + } + + return $grouped; + } + /** * @param array $data */ diff --git a/src/DataCollection/HttpHeaderNormalizer.php b/src/DataCollection/HttpHeaderNormalizer.php index b4fca59a1..31b0b3056 100644 --- a/src/DataCollection/HttpHeaderNormalizer.php +++ b/src/DataCollection/HttpHeaderNormalizer.php @@ -8,9 +8,7 @@ /** * Prepares header lines and maps for collection without invoking application - * callbacks. Already normalized PSR-7 headers do not require this step. - * - * @internal + * callbacks. Collection helpers accept the resulting lowercase header maps. */ final class HttpHeaderNormalizer { @@ -30,6 +28,7 @@ public static function normalize(array $headers): array { $normalized = []; + /** @mago-ignore analysis:mixed-assignment */ foreach ($headers as $name => $values) { // Numeric keys with array values can be valid header names in a // header map; only scalar entries are interpreted as raw lines. @@ -64,6 +63,8 @@ private static function appendHeader(array &$normalized, string $name, array $va return; } + $normalized[$name] = $normalized[$name] ?? []; + /** @mago-ignore analysis:mixed-assignment */ foreach ($values as $value) { // Header bags may contain nulls or objects. Do not call // __toString or retain objects for later serialization. diff --git a/src/DataCollection/KeyValueDataFilter.php b/src/DataCollection/KeyValueDataFilter.php index 133c78dca..3dec48166 100644 --- a/src/DataCollection/KeyValueDataFilter.php +++ b/src/DataCollection/KeyValueDataFilter.php @@ -4,22 +4,13 @@ namespace Sentry\DataCollection; -use Sentry\Util\Arr; - /** - * @internal - * * @phpstan-type KeyValueCollectionBehavior array{mode: 'off'|'denyList'|'allowList', terms: string[]} */ final class KeyValueDataFilter { public const FILTERED_VALUE = '[Filtered]'; - private const DEFAULT_BODY_FILTER_BEHAVIOR = [ - 'mode' => 'denyList', - 'terms' => [], - ]; - private const SENSITIVE_DATA_DENYLIST = [ 'auth', 'token', @@ -41,9 +32,9 @@ final class KeyValueDataFilter ]; /** - * Cookie headers that must always be filtered when headers are collected. + * Cookie headers are collected separately as cookie data. */ - private const SENSITIVE_HEADERS = [ + private const EXCLUDED_HEADERS = [ 'cookie', 'set-cookie', ]; @@ -75,7 +66,11 @@ public static function filterHeaders(array $headers, array $behavior): ?array foreach ($headers as $name => $values) { $name = (string) $name; - if (\in_array(strtolower($name), self::SENSITIVE_HEADERS, true) || self::shouldFilterValue($name, $behavior)) { + if (\in_array(strtolower($name), self::EXCLUDED_HEADERS, true)) { + continue; + } + + if (self::shouldFilterValue($name, $behavior)) { foreach ($values as $headerLine => $headerValue) { $values[$headerLine] = self::FILTERED_VALUE; } @@ -119,29 +114,24 @@ public static function filterKeyValueData(array $data, array $behavior): ?array } /** - * Filters HTTP body fields by key name while retaining scalar list values. + * Applies cookie policy to names, preserving all values of a repeated cookie. * - * @param array $data + * @param array $cookies + * + * @phpstan-param KeyValueCollectionBehavior $behavior * - * @return array + * @return array|null */ - public static function filterHttpBodyData(array $data): array + public static function filterCookies(array $cookies, array $behavior): ?array { - if (!Arr::isList($data)) { - return self::filterKeyValueData($data, self::DEFAULT_BODY_FILTER_BEHAVIOR) ?? []; + if ($behavior['mode'] === 'off') { + return null; } $filtered = []; - /** @mago-ignore analysis:mixed-assignment */ - foreach ($data as $value) { - if (\is_array($value)) { - $value = self::filterHttpBodyData($value); - } elseif ($value !== null && !\is_scalar($value)) { - $value = self::FILTERED_VALUE; - } - - $filtered[] = $value; + foreach ($cookies as $name => $value) { + $filtered[$name] = self::shouldFilterValue((string) $name, $behavior) ? self::FILTERED_VALUE : $value; } return $filtered; @@ -199,6 +189,8 @@ private static function matchesMandatoryDenyList(string $key): bool } /** + * Only the mandatory sensitive denylist uses substring matching. + * * @param string[] $terms */ private static function matchesAnyTerm(string $key, array $terms): bool @@ -206,7 +198,7 @@ private static function matchesAnyTerm(string $key, array $terms): bool $key = strtolower($key); foreach ($terms as $term) { - if (strpos($key, strtolower($term)) !== false) { + if ($key === strtolower($term)) { return true; } } diff --git a/src/DataCollection/RequestDataCollector.php b/src/DataCollection/RequestDataCollector.php index dfe065d4c..e0a213d43 100644 --- a/src/DataCollection/RequestDataCollector.php +++ b/src/DataCollection/RequestDataCollector.php @@ -4,9 +4,8 @@ namespace Sentry\DataCollection; -/** - * @internal - */ +use Sentry\Options; + final class RequestDataCollector { /** @@ -40,16 +39,54 @@ final class RequestDataCollector /** * @param DataCollectionOptions|null $dataCollection The data collection configuration, or null to preserve legacy behavior * @param bool $sendDefaultPii The legacy `send_default_pii` value - * @param string[] $piiSanitizeHeaders Lowercase header names sanitized in legacy mode + * @param string[]|null $piiSanitizeHeaders Explicit lowercase header restrictions; null uses legacy defaults only in legacy mode */ public function __construct( ?DataCollectionOptions $dataCollection, bool $sendDefaultPii, - array $piiSanitizeHeaders = self::DEFAULT_PII_SANITIZE_HEADERS + ?array $piiSanitizeHeaders = null ) { $this->dataCollection = $dataCollection; $this->sendDefaultPii = $sendDefaultPii; - $this->piiSanitizeHeaders = $piiSanitizeHeaders; + $this->piiSanitizeHeaders = $piiSanitizeHeaders ?? ($dataCollection === null ? self::DEFAULT_PII_SANITIZE_HEADERS : []); + } + + /** + * @param string[]|null $piiSanitizeHeaders + */ + public static function fromOptions(?Options $options, ?array $piiSanitizeHeaders = null): self + { + return new self( + DataCollectionOptions::fromOptions($options), + $options !== null && $options->shouldSendDefaultPii(), + $piiSanitizeHeaders + ); + } + + /** + * @template T + * + * @param array $data + * + * @return array + */ + public function collectUserInfo(array $data): array + { + return $this->shouldCollectUserInfo() ? $data : []; + } + + /** + * @return array + */ + public function collectClientIpData(?string $ipAddress): array + { + if ($ipAddress === null) { + return []; + } + + $data = $this->collectUserInfo(['ip_address' => $ipAddress]); + + return isset($data['ip_address']) ? ['net.peer.ip' => $data['ip_address']] : []; } public function usesDataCollection(): bool @@ -82,7 +119,7 @@ public function collectCookies(array $cookies): ?array return $this->sendDefaultPii ? $cookies : null; } - return KeyValueDataFilter::filterKeyValueData( + return KeyValueDataFilter::filterCookies( $cookies, $this->dataCollection->getCookies() ); @@ -96,45 +133,15 @@ public function collectCookies(array $cookies): ?array public function collectHeaders(array $headers): ?array { if ($this->dataCollection === null) { - return $this->sendDefaultPii ? $headers : $this->sanitizeLegacyHeaders($headers); + return $this->sendDefaultPii ? $headers : $this->sanitizeHeaders($headers); } - return KeyValueDataFilter::filterHeaders( + $headers = KeyValueDataFilter::filterHeaders( $headers, $this->dataCollection->getHttpHeaders()['request'] ); - } - - public function shouldCollectRequestBody(): bool - { - if ($this->dataCollection === null) { - // Legacy request body collection is controlled by max_request_body_size. - return true; - } - - return \in_array('incomingRequest', $this->dataCollection->getHttpBodies(), true); - } - - /** - * @param mixed $body - * - * @return mixed - */ - public function collectRequestBody($body) - { - if (empty($body) || !$this->shouldCollectRequestBody()) { - return null; - } - - if ($this->dataCollection === null) { - return $body; - } - - if (!\is_array($body)) { - return KeyValueDataFilter::FILTERED_VALUE; - } - return KeyValueDataFilter::filterHttpBodyData($body); + return $headers === null ? null : $this->sanitizeHeaders($headers); } /** @@ -142,7 +149,7 @@ public function collectRequestBody($body) * * @return array */ - private function sanitizeLegacyHeaders(array $headers): array + private function sanitizeHeaders(array $headers): array { $sanitized = []; diff --git a/src/Integration/RequestIntegration.php b/src/Integration/RequestIntegration.php index e4778a5f8..2599704bd 100644 --- a/src/Integration/RequestIntegration.php +++ b/src/Integration/RequestIntegration.php @@ -64,6 +64,11 @@ final class RequestIntegration implements IntegrationInterface */ private $options; + /** + * @var bool Whether the application explicitly supplied header restrictions + */ + private $hasConfiguredSanitizeHeaders; + /** * Constructor. * @@ -81,6 +86,7 @@ public function __construct(?RequestFetcherInterface $requestFetcher = null, arr $this->configureOptions($resolver); $this->requestFetcher = $requestFetcher ?? new RequestFetcher(); + $this->hasConfiguredSanitizeHeaders = \array_key_exists('pii_sanitize_headers', $options); /** @var array{pii_sanitize_headers: string[]} $resolvedOptions */ $resolvedOptions = $resolver->resolve($options); @@ -117,10 +123,9 @@ private function processEvent(Event $event, Options $options): void return; } - $collector = new RequestDataCollector( - $options->getDataCollection(), - $options->shouldSendDefaultPii(), - $this->options['pii_sanitize_headers'] + $collector = RequestDataCollector::fromOptions( + $options, + $this->hasConfiguredSanitizeHeaders ? $this->options['pii_sanitize_headers'] : null ); $queryString = $collector->collectQueryString($request->getUri()->getQuery()); @@ -133,8 +138,14 @@ private function processEvent(Event $event, Options $options): void $requestData['query_string'] = $queryString; } - if ($collector->shouldCollectUserInfo()) { - $this->addRequestUserInfo($event, $request, $requestData); + $serverParams = $request->getServerParams(); + if (!empty($serverParams['REMOTE_ADDR'])) { + /** @var string $ipAddress */ + $ipAddress = $serverParams['REMOTE_ADDR']; + $userData = $collector->collectUserInfo(['ip_address' => $ipAddress]); + if ($userData !== []) { + $this->addRequestUserInfo($event, $userData, $requestData); + } } $cookies = $collector->collectCookies($request->getCookieParams()); @@ -149,35 +160,31 @@ private function processEvent(Event $event, Options $options): void $requestData['headers'] = $headers; } - if ($collector->shouldCollectRequestBody()) { - $requestBody = $collector->collectRequestBody($this->captureRequestBody($options, $request)); - - if ($requestBody !== null) { + // Preserve existing body collection only when using the legacy configuration. + if (!$collector->usesDataCollection() && !\array_key_exists('data', $event->getRequest())) { + $requestBody = $this->captureRequestBody($options, $request); + if (!empty($requestBody)) { $requestData['data'] = $requestBody; } } - $event->setRequest($requestData); + // Explicit request fields take precedence, including null and empty values. + $event->setRequest($event->getRequest() + $requestData); } /** - * @param array $requestData + * @param array $userData + * @param array $requestData */ - private function addRequestUserInfo(Event $event, ServerRequestInterface $request, array &$requestData): void + private function addRequestUserInfo(Event $event, array $userData, array &$requestData): void { - $serverParams = $request->getServerParams(); - - if (empty($serverParams['REMOTE_ADDR'])) { - return; - } - $user = $event->getUser(); - $requestData['env'] = ['REMOTE_ADDR' => $serverParams['REMOTE_ADDR']]; + $requestData['env'] = ['REMOTE_ADDR' => $userData['ip_address']]; if ($user === null) { - $user = UserDataBag::createFromUserIpAddress($serverParams['REMOTE_ADDR']); + $user = UserDataBag::createFromUserIpAddress($userData['ip_address']); } elseif ($user->getIpAddress() === null) { - $user->setIpAddress($serverParams['REMOTE_ADDR']); + $user->setIpAddress($userData['ip_address']); } $event->setUser($user); @@ -242,9 +249,9 @@ private function captureRequestBody(Options $options, ServerRequestInterface $re * Create an array with the same structure as $uploadedFiles, but replacing * each UploadedFileInterface with an array of info. * - * @param array $uploadedFiles The uploaded files info from a PSR-7 server request + * @param array $uploadedFiles The uploaded files info from a PSR-7 server request * - * @return array + * @return array */ private function parseUploadedFiles(array $uploadedFiles): array { diff --git a/src/Tracing/GuzzleTracingMiddleware.php b/src/Tracing/GuzzleTracingMiddleware.php index 72deb3ecf..d1d205981 100644 --- a/src/Tracing/GuzzleTracingMiddleware.php +++ b/src/Tracing/GuzzleTracingMiddleware.php @@ -6,14 +6,12 @@ use GuzzleHttp\Exception\RequestException as GuzzleRequestException; 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\DataCollection\HttpBodyCollector; +use Sentry\DataCollection\DataCollectionOptions; use Sentry\DataCollection\HttpDataCollector; -use Sentry\DataCollection\KeyValueDataFilter; +use Sentry\DataCollection\HttpHeaderNormalizer; use Sentry\Options; use Sentry\SentrySdk; use Sentry\State\HubInterface; @@ -44,16 +42,13 @@ public static function trace(?HubInterface $hub = null): \Closure ]); $sdkOptions = $client !== null ? $client->getOptions() : null; - $dataCollection = $sdkOptions !== null ? $sdkOptions->getDataCollection() : null; + $dataCollection = DataCollectionOptions::fromOptions($sdkOptions); $spanAndBreadcrumbData = [ 'http.request.method' => $request->getMethod(), 'http.request.body.size' => $requestBody->getSize(), ]; - $queryString = HttpDataCollector::collectQueryString($dataCollection, $requestUri->getQuery()); - if ($queryString !== null) { - $spanAndBreadcrumbData['http.query'] = $queryString; - } + $spanAndBreadcrumbData += HttpDataCollector::collectQueryData($dataCollection, $requestUri->getQuery()); if ($requestUri->getFragment() !== '') { $spanAndBreadcrumbData['http.fragment'] = $requestUri->getFragment(); } @@ -68,14 +63,13 @@ public static function trace(?HubInterface $hub = null): \Closure $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. + if ($dataCollection !== null) { + // Headers and cookies can be sizeable, so keep them on the recorded span instead of duplicating them on its breadcrumb. $spanData = array_merge( $spanData, - self::collectRequestSpanData( - $sdkOptions, - $request, - $requestBody + HttpDataCollector::collectRequestData( + $dataCollection, + HttpHeaderNormalizer::normalize($request->getHeaders()) ) ); } @@ -103,7 +97,7 @@ public static function trace(?HubInterface $hub = null): \Closure } } - $handlerPromiseCallback = static function ($responseOrException) use ($hub, $spanAndBreadcrumbData, $spanData, $childSpan, $parentSpan, $collectedUrl, $dataCollection, $sdkOptions) { + $handlerPromiseCallback = static function ($responseOrException) use ($hub, $spanAndBreadcrumbData, $spanData, $childSpan, $parentSpan, $collectedUrl, $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 @@ -141,7 +135,7 @@ public static function trace(?HubInterface $hub = null): \Closure $spanData = array_merge( $spanData, $spanAndBreadcrumbData, - self::collectResponseSpanData($sdkOptions, $response) + self::collectResponseSpanData($dataCollection, $response) ); $childSpan->setStatus(SpanStatus::createFromHttpStatusCode($response->getStatusCode())); if ($dataCollection === null) { @@ -179,123 +173,12 @@ public static function trace(?HubInterface $hub = null): \Closure /** * @return array */ - private static function collectRequestSpanData(Options $options, RequestInterface $request, StreamInterface $body): array - { - $dataCollection = $options->getDataCollection(); - if ($dataCollection === null) { - return []; - } - - $data = HttpDataCollector::collectHeaders($dataCollection, $request->getHeaders(), 'request'); - $maxBodyLength = HttpBodyCollector::getMaxBodyLength($options, 'outgoingRequest'); - if ($maxBodyLength === 0) { - return $data; - } - - $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(?Options $options, ResponseInterface $response): array + private static function collectResponseSpanData(?DataCollectionOptions $options, ResponseInterface $response): array { - if ($options === null) { - return []; - } - - $dataCollection = $options->getDataCollection(); - if ($dataCollection === null) { - return []; - } - - $data = HttpDataCollector::collectHeaders($dataCollection, $response->getHeaders(), 'response'); - - $maxBodyLength = HttpBodyCollector::getMaxBodyLength($options, 'incomingResponse'); - if ($maxBodyLength === 0) { - return $data; - } - - $collectedBody = self::collectBody($response->getBody(), $response->getHeaderLine('Content-Type'), $maxBodyLength); - - if ($collectedBody !== null) { - $data['http.response.body.data'] = $collectedBody; - } - - return $data; - } - - /** - * @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; - } - - if (!HttpBodyCollector::isSupportedContentType($contentType)) { - return KeyValueDataFilter::FILTERED_VALUE; - } - - // The size can be unknown (a null body size), so readBody() enforces the limit again after reading. - $contents = self::readBody($body, $maxBodyLength); - if ($contents === null) { - return null; - } - - $parsedBody = HttpBodyCollector::parse($contents, $contentType); - - return $parsedBody === null ? KeyValueDataFilter::FILTERED_VALUE : HttpBodyCollector::collect($parsedBody); - } - - 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. - } + return HttpDataCollector::collectResponseData( + $options, + HttpHeaderNormalizer::normalize($response->getHeaders()) + ); } private static function shouldAttachTracingHeaders(?Options $options, RequestInterface $request): bool diff --git a/tests/DataCollection/DataCollectionOptionsTest.php b/tests/DataCollection/DataCollectionOptionsTest.php index 1ff747466..b05bc8463 100644 --- a/tests/DataCollection/DataCollectionOptionsTest.php +++ b/tests/DataCollection/DataCollectionOptionsTest.php @@ -5,10 +5,70 @@ namespace Sentry\Tests\DataCollection; use PHPUnit\Framework\TestCase; +use Sentry\ClientInterface; use Sentry\DataCollection\DataCollectionOptions; +use Sentry\Options; +use Sentry\State\HubInterface; final class DataCollectionOptionsTest extends TestCase { + public function testFromHubWithoutClientReturnsNull(): void + { + $hub = $this->createMock(HubInterface::class); + $hub->expects($this->once())->method('getClient')->willReturn(null); + + $this->assertNull(DataCollectionOptions::fromHub($hub)); + } + + public function testFromHubPreservesLegacyConfiguration(): void + { + foreach ([new Options(), new Options(['data_collection' => null]), new Options(['send_default_pii' => true])] as $options) { + $client = $this->createMock(ClientInterface::class); + $client->method('getOptions')->willReturn($options); + $hub = $this->createMock(HubInterface::class); + $hub->method('getClient')->willReturn($client); + + $this->assertNull(DataCollectionOptions::fromHub($hub)); + $this->assertNull($options->getDataCollection()); + } + } + + public function testFromHubReturnsExistingCollectionOptions(): void + { + foreach ([[], ['user_info' => false]] as $configuration) { + $options = new Options(['data_collection' => $configuration]); + $client = $this->createMock(ClientInterface::class); + $client->method('getOptions')->willReturn($options); + $hub = $this->createMock(HubInterface::class); + $hub->method('getClient')->willReturn($client); + + $collection = DataCollectionOptions::fromHub($hub); + $this->assertNotNull($collection); + $this->assertSame($options->getDataCollection(), $collection); + } + } + + public function testFromOptionsPreservesMissingCollection(): void + { + foreach ([null, new Options(), new Options(['data_collection' => null]), new Options(['send_default_pii' => true])] as $options) { + $this->assertNull(DataCollectionOptions::fromOptions($options)); + if ($options !== null) { + $this->assertNull($options->getDataCollection()); + } + } + } + + public function testFromOptionsReturnsExistingCollection(): void + { + foreach ([[], ['user_info' => false]] as $configuration) { + $options = new Options(['data_collection' => $configuration]); + $collection = DataCollectionOptions::fromOptions($options); + + $this->assertNotNull($collection); + $this->assertSame($options->getDataCollection(), $collection); + } + } + public function testDefaults(): void { $options = new DataCollectionOptions(); diff --git a/tests/DataCollection/HttpBodyCollectorTest.php b/tests/DataCollection/HttpBodyCollectorTest.php deleted file mode 100644 index 3f3f337fb..000000000 --- a/tests/DataCollection/HttpBodyCollectorTest.php +++ /dev/null @@ -1,184 +0,0 @@ -assertSame(0, HttpBodyCollector::getMaxBodyLength(new Options(), $type)); - $this->assertSame(0, HttpBodyCollector::getMaxBodyLength(new Options(['data_collection' => ['http_bodies' => []]]), $type)); - foreach (['never' => 0, 'none' => 0, 'small' => 1000, 'medium' => 10000, 'always' => 100000] as $size => $limit) { - $options = new Options(['data_collection' => [], 'max_request_body_size' => $size]); - $this->assertSame(substr($type, -7) === 'Request' ? $limit : 100000, HttpBodyCollector::getMaxBodyLength($options, $type)); - } - } - - $options = new Options(['data_collection' => ['http_bodies' => ['outgoingResponse']]]); - $this->assertSame(0, HttpBodyCollector::getMaxBodyLength($options, 'incomingResponse')); - $this->assertSame(100000, HttpBodyCollector::getMaxBodyLength($options, 'outgoingResponse')); - } - - public function testContentTypeSupport(): void - { - foreach ([ - 'application/json' => true, - ' Application/Problem+JSON ; charset=UTF-8' => true, - ' APPLICATION/X-WWW-FORM-URLENCODED ; charset=UTF-8' => true, - '' => false, - 'text/plain' => false, - 'application/jsonp' => false, - 'application/json+unknown' => false, - 'multipart/form-data; boundary=123' => false, - ] as $contentType => $expected) { - $this->assertSame($expected, HttpBodyCollector::isSupportedContentType($contentType)); - } - } - - /** - * @dataProvider parseProvider - * - * @param array|null $expected - */ - public function testParse(string $body, string $contentType, ?array $expected): void - { - $this->assertSame($expected, HttpBodyCollector::parse($body, $contentType)); - } - - public function parseProvider(): \Generator - { - yield 'JSON' => ['{"name":"Alice","password":"secret"}', 'application/json', ['name' => 'Alice', 'password' => 'secret']]; - yield 'JSON suffix' => ['{"token":"secret"}', ' Application/Problem+JSON ; charset=UTF-8', ['token' => 'secret']]; - yield 'form' => ['name=Alice&password=secret', 'application/x-www-form-urlencoded; charset=UTF-8', ['name' => 'Alice', 'password' => 'secret']]; - yield 'uppercase form' => ['password=secret', ' APPLICATION/X-WWW-FORM-URLENCODED ; charset=UTF-8', ['password' => 'secret']]; - yield 'repeated form keys' => ['name=Alice&name=Bob', 'application/x-www-form-urlencoded', ['name' => ['Alice', 'Bob']]]; - yield 'JSON list' => ['["secret",{"name":"Alice"}]', 'application/json', ['secret', ['name' => 'Alice']]]; - yield 'invalid JSON' => ['{invalid', 'application/json', null]; - yield 'scalar JSON' => ['42', 'application/json', null]; - yield 'boolean JSON' => ['false', 'application/json', null]; - yield 'string JSON' => ['"secret"', 'application/json', null]; - yield 'null JSON' => ['null', 'application/json', null]; - yield 'unsupported type' => ['raw secret', 'text/plain', null]; - yield 'multipart' => ['raw secret', 'multipart/form-data; boundary=123', null]; - yield 'invalid UTF-8' => ["{\"value\":\"\xff\"}", 'application/json', null]; - yield 'empty string' => ['', 'application/json', null]; - yield 'empty JSON object' => ['{}', 'application/json', []]; - yield 'empty JSON list' => ['[]', 'application/json', []]; - } - - /** - * @dataProvider bodyProvider - * - * @param array $body - * @param array $expected - */ - public function testCollect(array $body, array $expected): void - { - $this->assertSame($expected, HttpBodyCollector::collect($body)); - } - - public function bodyProvider(): \Generator - { - yield 'parsed data' => [['profile' => ['name' => 'Alice', 'password' => 'secret']], ['profile' => ['name' => 'Alice', 'password' => '[Filtered]']]]; - yield 'nested object' => [['profile' => (object) ['password' => 'secret']], ['profile' => '[Filtered]']]; - yield 'unkeyed data' => [['secret', ['name' => 'Alice']], ['secret', ['name' => 'Alice']]]; - yield 'scalar list' => [['secret', 'foo', false, null], ['secret', 'foo', false, null]]; - yield 'sensitive parent' => [['token' => ['foo']], ['token' => '[Filtered]']]; - yield 'empty array' => [[], []]; - } - - public function testParsedBodiesAreCollected(): void - { - $body = HttpBodyCollector::parse('["secret",{"password":"value","name":"Alice"}]', 'application/json'); - $this->assertNotNull($body); - $this->assertSame(['secret', ['password' => '[Filtered]', 'name' => 'Alice']], HttpBodyCollector::collect($body)); - } - - public function testNestedObjectsAreFilteredWithoutCallbacksOrInputChanges(): void - { - $object = new class implements \JsonSerializable { - public function jsonSerialize(): array - { - throw new \LogicException('Must not serialize application objects'); - } - - public function __toString(): string - { - throw new \LogicException('Must not stringify application objects'); - } - }; - $plain = (object) ['password' => 'secret']; - $body = ['object' => $object, 'profile' => $plain]; - $this->assertSame(['object' => '[Filtered]', 'profile' => '[Filtered]'], HttpBodyCollector::collect($body)); - $this->assertSame(['object' => $object, 'profile' => $plain], $body); - $this->assertSame('secret', $plain->password); - $subclass = new class extends \stdClass implements \IteratorAggregate { - public function getIterator(): \Traversable - { - throw new \LogicException('Must not iterate application objects'); - } - }; - $this->assertSame(['object' => '[Filtered]'], HttpBodyCollector::collect(['object' => $subclass])); - } - - public function testResourcesAreNotReadOrClosed(): void - { - $resource = fopen('php://temp', 'r+'); - $this->assertIsResource($resource); - try { - fwrite($resource, 'prefix:secret'); - fseek($resource, 7); - $this->assertSame(['file' => '[Filtered]'], HttpBodyCollector::collect(['file' => $resource])); - $this->assertSame(7, ftell($resource)); - $this->assertSame('secret', stream_get_contents($resource)); - } finally { - fclose($resource); - } - $this->assertSame(['file' => '[Filtered]'], HttpBodyCollector::collect(['file' => $resource])); - } - - public function testNormalizationDepthIsBounded(): void - { - $body = ['value' => null]; - for ($i = 0; $i < 31; ++$i) { - $body = ['child' => $body]; - } - - $this->assertSame($body, HttpBodyCollector::collect($body)); - $this->assertNull(HttpBodyCollector::collect(['child' => $body])); - - $parsed = HttpBodyCollector::parse(JSON::encode($body), 'application/json'); - $this->assertNotNull($parsed); - $this->assertSame($body, HttpBodyCollector::collect($parsed)); - $parsed = HttpBodyCollector::parse(JSON::encode(['child' => $body]), 'application/json'); - $this->assertNotNull($parsed); - $this->assertNull(HttpBodyCollector::collect($parsed)); - } - - public function testNormalizationPreservesReferencedInput(): void - { - $object = (object) ['value' => 'original']; - $nested = ['value' => &$object]; - $body = ['nested' => &$nested]; - $this->assertSame(['nested' => ['value' => '[Filtered]']], HttpBodyCollector::collect($body)); - $this->assertSame($object, $nested['value']); - $this->assertSame($object, $body['nested']['value']); - $this->assertSame('original', $object->value); - } - - public function testRecursiveArraysAreOmitted(): void - { - $array = []; - $array['self'] = &$array; - $this->assertNull(HttpBodyCollector::collect($array)); - } -} diff --git a/tests/DataCollection/HttpDataCollectorTest.php b/tests/DataCollection/HttpDataCollectorTest.php index f5f7d8433..7b298644a 100644 --- a/tests/DataCollection/HttpDataCollectorTest.php +++ b/tests/DataCollection/HttpDataCollectorTest.php @@ -11,12 +11,109 @@ final class HttpDataCollectorTest extends TestCase { + public function testRequestDataCollectsHeadersAndCookies(): void + { + $data = HttpDataCollector::collectRequestData(new DataCollectionOptions(), [ + 'authorization' => ['secret'], 'cookie' => ['theme=dark; session_id=secret'], + ]); + $this->assertSame([ + 'http.request.header.authorization' => ['[Filtered]'], + 'http.request.header.cookie.theme' => 'dark', + 'http.request.header.cookie.session_id' => '[Filtered]', + ], $data); + $this->assertSame([], HttpDataCollector::collectRequestData(null, [])); + } + public function testCollectQueryStringPreservesLegacyBehavior(): void { $this->assertSame('token=secret&q=a%20b', HttpDataCollector::collectQueryString(null, 'token=secret&q=a%20b')); $this->assertNull(HttpDataCollector::collectQueryString(null, '')); } + public function testParsedCookiesAreCollectedIndependentlyOfHeaders(): void + { + $headers = ['x-test' => ['visible'], 'cookie' => ['theme=raw'], 'set-cookie' => ['theme=raw']]; + foreach ([null, [], ['http_headers' => ['mode' => 'off']], ['cookies' => ['mode' => 'off']]] as $configuration) { + $options = $configuration === null ? null : new DataCollectionOptions($configuration); + $request = HttpDataCollector::collectRequestData($options, $headers, ['theme' => 'parsed', 'session_id' => 'secret']); + $response = HttpDataCollector::collectResponseData($options, $headers, [ + ['theme', 'first'], ['theme', 'second'], ['locale', null], ['session_id', 'secret'], + ]); + foreach (['request' => $request, 'response' => $response] as $direction => $data) { + if ($configuration !== null && ($configuration['http_headers']['mode'] ?? null) !== 'off') { + $this->assertSame(['visible'], $data['http.' . $direction . '.header.x-test']); + } else { + $this->assertArrayNotHasKey('http.' . $direction . '.header.x-test', $data); + } + $cookiePrefix = 'http.' . $direction . '.header.' . ($direction === 'request' ? 'cookie.' : 'set_cookie.'); + if ($configuration !== null && ($configuration['cookies']['mode'] ?? null) !== 'off') { + $this->assertSame($direction === 'request' ? 'parsed' : ['first', 'second'], $data[$cookiePrefix . 'theme']); + $this->assertSame('[Filtered]', $data[$cookiePrefix . 'session_id']); + } else { + $this->assertArrayNotHasKey($cookiePrefix . 'theme', $data); + $this->assertArrayNotHasKey($cookiePrefix . 'session_id', $data); + } + $this->assertArrayNotHasKey('http.' . $direction . '.header.cookie', $data); + $this->assertArrayNotHasKey('http.' . $direction . '.header.set-cookie', $data); + } + } + } + + public function testEmptyParsedCookiesDoNotFallBackToRawHeaders(): void + { + $options = new DataCollectionOptions(); + $this->assertSame([], HttpDataCollector::collectRequestData($options, ['cookie' => ['theme=raw']], [])); + $this->assertSame([], HttpDataCollector::collectResponseData($options, ['set-cookie' => ['theme=raw']], [])); + $this->assertSame(['http.request.header.cookie.theme' => 'raw'], HttpDataCollector::collectRequestData($options, ['cookie' => ['theme=raw']])); + $this->assertSame(['http.response.header.set_cookie.theme' => 'raw'], HttpDataCollector::collectResponseData($options, ['set-cookie' => ['theme=raw']])); + } + + public function testUrlSelectsLegacyInputOnlyWithoutDataCollection(): void + { + $declared = 'https://example.com/?tag=a&tag=b&%74oken=secret&q=a+b'; + $legacy = 'https://example.com/?q=a%20b&tag=b&token=secret'; + $this->assertSame($legacy, HttpDataCollector::collectUrl(null, $declared, $legacy)); + $this->assertSame('https://example.com/?tag=a&tag=b&%74oken=[Filtered]&q=a+b', HttpDataCollector::collectUrl(new DataCollectionOptions(), $declared, $legacy)); + $this->assertSame('https://example.com/', HttpDataCollector::collectUrl(new DataCollectionOptions(['url_query_params' => ['mode' => 'off']]), $declared, $legacy)); + } + + public function testRemovedHeadersAreNotCollected(): void + { + $options = new DataCollectionOptions(); + $this->assertSame([], HttpDataCollector::collectRequestHeaders($options, ['x-removed' => []])); + $this->assertSame([], HttpDataCollector::collectResponseHeaders($options, ['x-removed' => []])); + } + + public function testResponseDataCollectsHeadersWithoutBodyData(): void + { + $headers = ['content-type' => ['application/problem+json']]; + $this->assertSame([ + 'http.response.header.content-type' => ['application/problem+json'], + ], HttpDataCollector::collectResponseData(new DataCollectionOptions(), $headers)); + $this->assertSame([], HttpDataCollector::collectResponseData(null, $headers)); + } + + public function testCookiePairsPreserveDuplicateAndNullValues(): void + { + $this->assertSame(['theme' => [null, 'light', 'dark'], 'language' => 'en'], HttpDataCollector::groupCookieValues([ + ['theme', null], ['theme', 'light'], ['language', 'en'], ['theme', 'dark'], + ])); + } + + public function testQueryDataPreservesEncodingAndOmitsUncollectedQueries(): void + { + $this->assertSame([], HttpDataCollector::collectQueryData(new DataCollectionOptions(), '')); + $this->assertSame([], HttpDataCollector::collectQueryData(new DataCollectionOptions(['url_query_params' => ['mode' => 'off']]), 'token=secret')); + $this->assertSame(['http.query' => 'token=secret'], HttpDataCollector::collectQueryData(null, 'token=secret')); + $this->assertSame( + ['http.query' => '%74oken=[Filtered]&page=new&q=a+b'], + HttpDataCollector::collectQueryData( + new DataCollectionOptions(), + '%74oken=secret&page=new&q=a+b' + ) + ); + } + public function testCollectQueryStringPreservesEncoding(): void { $this->assertSame( @@ -54,17 +151,17 @@ public function urlDataProvider(): \Generator yield 'relative URL' => [[], '/a?token=secret', '/a?token=[Filtered]']; } - public function testCollectHeadersFiltersSensitiveHeadersAndCookies(): void + public function testCollectHeadersFiltersSensitiveHeadersAndExcludesCookies(): void { $this->assertSame([ - 'http.request.header.cookie' => ['[Filtered]', '[Filtered]'], 'http.request.header.authorization' => ['[Filtered]'], 'http.request.header.x-request-id' => ['request-id'], - ], HttpDataCollector::collectHeaders(new DataCollectionOptions(), [ - 'Authorization' => ['Bearer secret'], - 'X-Request-ID' => ['request-id'], - 'Cookie' => ['session_id=secret', 'theme=dark'], - ], 'request')); + ], HttpDataCollector::collectRequestHeaders(new DataCollectionOptions(), [ + 'authorization' => ['Bearer secret'], + 'x-request-id' => ['request-id'], + 'cookie' => ['session_id=secret', 'theme=dark'], + 'set-cookie' => ['theme=light'], + ])); } public function testHeaderDirectionsAndCookiesAreIndependent(): void @@ -77,15 +174,62 @@ public function testHeaderDirectionsAndCookiesAreIndependent(): void ], ]); $headers = ['x-test' => ['plain'], 'x-other' => ['other'], 'authorization' => ['secret'], 'set-cookie' => ['theme=dark']]; - $this->assertSame([], HttpDataCollector::collectHeaders($options, $headers, 'request')); + $this->assertSame([], HttpDataCollector::collectRequestHeaders($options, $headers)); $this->assertSame([ 'http.response.header.x-test' => ['plain'], 'http.response.header.x-other' => ['[Filtered]'], 'http.response.header.authorization' => ['[Filtered]'], - ], HttpDataCollector::collectHeaders($options, $headers, 'response')); + ], HttpDataCollector::collectResponseHeaders($options, $headers)); $options = new DataCollectionOptions(['http_headers' => ['mode' => 'off']]); - $this->assertSame(['http.response.header.set-cookie' => ['[Filtered]']], HttpDataCollector::collectHeaders($options, $headers, 'response')); - $this->assertSame([], HttpDataCollector::collectHeaders($options, ['cookie' => []], 'request')); + $this->assertSame([], HttpDataCollector::collectResponseHeaders($options, $headers)); + } + + public function testHeadersCannotOptIntoCookieHeaders(): void + { + foreach (['collectRequestHeaders', 'collectResponseHeaders'] as $method) { + foreach (['off', 'denyList', 'allowList'] as $mode) { + $options = new DataCollectionOptions([ + 'http_headers' => ['mode' => 'allowList', 'terms' => ['cookie']], + 'cookies' => ['mode' => $mode, 'terms' => ['theme']], + ]); + $this->assertSame([], HttpDataCollector::$method($options, [ + 'cookie' => ['theme=dark'], + 'set-cookie' => ['theme=light'], + ])); + } + } + } + + public function testParsedCookiesAreIndependentOfHeaders(): void + { + foreach (['collectRequestCookies' => 'http.request.header.cookie.', 'collectResponseCookies' => 'http.response.header.set_cookie.'] as $method => $prefix) { + foreach ([ + ['mode' => 'denyList'], + ['mode' => 'allowList', 'terms' => ['theme', 'session']], + ] as $behavior) { + $options = new DataCollectionOptions(['cookies' => $behavior, 'http_headers' => ['mode' => 'off']]); + $this->assertSame([ + $prefix . 'theme' => 'dark', + $prefix . 'SESSION_id' => '[Filtered]', + ], HttpDataCollector::$method($options, ['theme' => 'dark', 'SESSION_id' => 'secret'])); + } + $options = new DataCollectionOptions(['cookies' => ['mode' => 'off']]); + $this->assertSame([], HttpDataCollector::$method($options, ['theme' => 'dark'])); + $options = new DataCollectionOptions(['cookies' => ['mode' => 'denyList', 'terms' => ['theme']]]); + $this->assertSame([$prefix . 'theme' => '[Filtered]'], HttpDataCollector::$method($options, ['theme' => 'dark'])); + } + } + + public function testCookieHeaderParsing(): void + { + $this->assertSame(['theme' => ['dark', 'light'], 'session' => 'a=b', 'empty' => ''], HttpDataCollector::parseRequestCookies([ + 'theme=dark; session=a=b; empty=; malformed; =ignored', 'theme=light', + ])); + $this->assertSame(['theme' => ['dark', 'light'], 'session' => 'a=b'], HttpDataCollector::parseResponseCookies([ + 'theme=dark; Path=/; Expires=Wed, 09 Jun 2027 10:18:14 GMT', + 'theme=light; Path=/other; Secure', + 'session=a=b; HttpOnly', 'malformed', + ])); } public function testAutomaticSpanDataOnlyFillsMissingFields(): void diff --git a/tests/DataCollection/HttpHeaderNormalizerTest.php b/tests/DataCollection/HttpHeaderNormalizerTest.php index bb28113e0..18dabbec4 100644 --- a/tests/DataCollection/HttpHeaderNormalizerTest.php +++ b/tests/DataCollection/HttpHeaderNormalizerTest.php @@ -111,7 +111,7 @@ public function testNullableAndScalarHeaderBagValuesAreNormalized(): void public function testEmptyAndMalformedHeadersAreIgnored(): void { - $this->assertSame([], HttpHeaderNormalizer::normalize([ + $this->assertSame(['x-removed' => []], HttpHeaderNormalizer::normalize([ 'Invalid', ': empty header name', 'X-Removed' => [], @@ -127,9 +127,8 @@ public function testNormalizedHeadersCanBeCollected(): void 'X-Request-ID' => 'request-id', ]); $this->assertSame([ - 'http.request.header.cookie' => ['[Filtered]'], 'http.request.header.authorization' => ['[Filtered]'], 'http.request.header.x-request-id' => ['request-id'], - ], HttpDataCollector::collectHeaders(new DataCollectionOptions(), $headers, 'request')); + ], HttpDataCollector::collectRequestHeaders(new DataCollectionOptions(), $headers)); } } diff --git a/tests/DataCollection/KeyValueDataFilterTest.php b/tests/DataCollection/KeyValueDataFilterTest.php index d588a27ea..e9e981d08 100644 --- a/tests/DataCollection/KeyValueDataFilterTest.php +++ b/tests/DataCollection/KeyValueDataFilterTest.php @@ -36,7 +36,7 @@ public function testFilterKeyValueDataAppliesMandatoryDenyList(): void public function testFilterKeyValueDataCombinesMandatoryAndCustomDenyListTerms(): void { - $behavior = ['mode' => 'denyList', 'terms' => ['custom']]; + $behavior = ['mode' => 'denyList', 'terms' => ['custom-field']]; $filtered = KeyValueDataFilter::filterKeyValueData([ 'authorization' => 'secret', @@ -56,12 +56,12 @@ public function testFilterKeyValueDataAppliesAllowList(): void $behavior = ['mode' => 'allowList', 'terms' => ['theme']]; $filtered = KeyValueDataFilter::filterKeyValueData([ - 'preferred-theme' => 'dark', + 'theme' => 'dark', 'tracking_id' => '12345', ], $behavior); $this->assertSame([ - 'preferred-theme' => 'dark', + 'theme' => 'dark', 'tracking_id' => '[Filtered]', ], $filtered); } @@ -96,48 +96,6 @@ public function testFilterKeyValueDataFiltersNestedData(): void ], $filtered); } - public function testFilterHttpBodyDataFiltersSensitiveKeysWithinLists(): void - { - $this->assertSame([ - [ - 'password' => '[Filtered]', - 'name' => 'alice', - ], - 'unkeyed secret', - ], KeyValueDataFilter::filterHttpBodyData([ - [ - 'password' => 'secret', - 'name' => 'alice', - ], - 'unkeyed secret', - ])); - } - - public function testFilterHttpBodyDataPreservesScalarListValues(): void - { - $data = ['secret', 'foo', false, null, 123, ['token', 'password']]; - - $this->assertSame($data, KeyValueDataFilter::filterHttpBodyData($data)); - } - - public function testFilterHttpBodyDataKeepsKeysAndFiltersSensitiveParentValues(): void - { - $this->assertSame([ - 2 => 'secret', - 'items' => ['secret', ['PaSsWoRd' => '[Filtered]', 'name' => 'secret']], - 'token' => '[Filtered]', - ], KeyValueDataFilter::filterHttpBodyData([ - 2 => 'secret', - 'items' => ['secret', ['PaSsWoRd' => 'value', 'name' => 'secret']], - 'token' => ['foo'], - ])); - } - - public function testFilterHttpBodyDataStillFiltersOpaqueListValues(): void - { - $this->assertSame(['[Filtered]'], KeyValueDataFilter::filterHttpBodyData([new \stdClass()])); - } - public function testFilterHeadersReturnsNullWhenCollectionIsOff(): void { $behavior = ['mode' => 'off', 'terms' => ['x-request-id']]; @@ -163,19 +121,17 @@ public function testFilterHeadersAppliesDenyListToEveryHeaderLine(): void ], $filtered); } - public function testFilterHeadersAlwaysFiltersCookieHeaders(): void + public function testFilterHeadersAlwaysExcludesCookieHeaders(): void { $behavior = ['mode' => 'allowList', 'terms' => ['cookie', 'set-cookie', 'x-request-id']]; $filtered = KeyValueDataFilter::filterHeaders([ - 'Cookie' => ['session_id=secret; theme=dark'], - 'Set-Cookie' => ['session_id=secret'], + 'CoOkIe' => ['session_id=secret; theme=dark'], + 'SET-COOKIE' => ['session_id=secret'], 'X-Request-Id' => ['request-id'], ], $behavior); $this->assertSame([ - 'Cookie' => ['[Filtered]'], - 'Set-Cookie' => ['[Filtered]'], 'X-Request-Id' => ['request-id'], ], $filtered); } @@ -183,7 +139,7 @@ public function testFilterHeadersAlwaysFiltersCookieHeaders(): void public function testFilterHeadersAppliesExtendedDenyTerms(): void { $defaultBehavior = ['mode' => 'denyList', 'terms' => []]; - $extendedBehavior = ['mode' => 'denyList', 'terms' => ['forwarded', '-ip', 'remote-', 'via', '-user']]; + $extendedBehavior = ['mode' => 'denyList', 'terms' => ['x-forwarded-for', 'x-real-ip']]; $headers = [ 'X-Forwarded-For' => ['203.0.113.7'], 'X-Real-IP' => ['203.0.113.7'], @@ -198,7 +154,7 @@ public function testFilterHeadersAppliesExtendedDenyTerms(): void public function testFilterHeadersAppliesAllowList(): void { - $behavior = ['mode' => 'allowList', 'terms' => ['request-id']]; + $behavior = ['mode' => 'allowList', 'terms' => ['x-request-id']]; $filtered = KeyValueDataFilter::filterHeaders([ 'X-Request-Id' => ['request-id'], @@ -211,6 +167,36 @@ public function testFilterHeadersAppliesAllowList(): void ], $filtered); } + public function testCustomTermsMatchWholeNamesAcrossCategories(): void + { + foreach (['allowList', 'denyList'] as $mode) { + $behavior = ['mode' => $mode, 'terms' => ['THEME', 'api_token']]; + $input = ['theme' => 'dark', 'user_theme' => 'light', 'api_token' => 'secret']; + $expected = [ + 'theme' => $mode === 'allowList' ? 'dark' : '[Filtered]', + 'user_theme' => $mode === 'allowList' ? '[Filtered]' : 'light', + 'api_token' => '[Filtered]', + ]; + + $this->assertSame($expected, KeyValueDataFilter::filterCookies($input, $behavior)); + $this->assertSame($expected, KeyValueDataFilter::filterKeyValueData($input, $behavior)); + $this->assertSame( + array_map(static function (string $value): array { return [$value]; }, $expected), + KeyValueDataFilter::filterHeaders(array_map(static function (string $value): array { return [$value]; }, $input), $behavior) + ); + $this->assertSame( + '%74heme=' . $expected['theme'] . '&user_theme=' . $expected['user_theme'] . '&api_token=[Filtered]&q=' . ($mode === 'allowList' ? '[Filtered]' : 'a%20b'), + KeyValueDataFilter::filterQueryString('%74heme=dark&user_theme=light&api_token=secret&q=a%20b', $behavior) + ); + } + } + + public function testEmptyCustomTermDoesNotMatchEveryName(): void + { + $this->assertSame(['theme' => 'dark'], KeyValueDataFilter::filterCookies(['theme' => 'dark'], ['mode' => 'denyList', 'terms' => ['']])); + $this->assertSame(['theme' => '[Filtered]'], KeyValueDataFilter::filterCookies(['theme' => 'dark'], ['mode' => 'allowList', 'terms' => ['']])); + } + public function testFilterQueryStringReturnsNullWhenCollectionIsOff(): void { $behavior = ['mode' => 'off', 'terms' => ['page']]; diff --git a/tests/DataCollection/RequestDataCollectorTest.php b/tests/DataCollection/RequestDataCollectorTest.php index 4a78d1eba..05c619163 100644 --- a/tests/DataCollection/RequestDataCollectorTest.php +++ b/tests/DataCollection/RequestDataCollectorTest.php @@ -7,9 +7,54 @@ use PHPUnit\Framework\TestCase; use Sentry\DataCollection\DataCollectionOptions; use Sentry\DataCollection\RequestDataCollector; +use Sentry\Options; final class RequestDataCollectorTest extends TestCase { + /** + * @dataProvider userCollectionProvider + * + * @param array|null $configuration + */ + public function testCollectUserInfoAndClientIpFollowConfiguration(?array $configuration, bool $enabled): void + { + $collector = RequestDataCollector::fromOptions($configuration === null ? null : new Options($configuration)); + $data = ['id' => 'alice', 'ip_address' => '203.0.113.7', 'impersonator_username' => 'admin']; + + $this->assertSame($enabled ? $data : [], $collector->collectUserInfo($data)); + $this->assertSame($enabled ? ['net.peer.ip' => '203.0.113.7'] : [], $collector->collectClientIpData('203.0.113.7')); + $this->assertSame([], $collector->collectClientIpData(null)); + } + + /** + * @return \Generator|null, bool}> + */ + public function userCollectionProvider(): \Generator + { + yield 'no options' => [null, false]; + foreach ([false, true] as $pii) { + $legacy = ['send_default_pii' => $pii]; + $suffix = ' pii=' . (int) $pii; + yield 'legacy' . $suffix => [$legacy, $pii]; + yield 'null collection' . $suffix => [$legacy + ['data_collection' => null], $pii]; + yield 'configured defaults' . $suffix => [$legacy + ['data_collection' => []], true]; + yield 'unrelated override' . $suffix => [$legacy + ['data_collection' => ['http_headers' => ['mode' => 'off']]], true]; + yield 'user info enabled' . $suffix => [$legacy + ['data_collection' => ['user_info' => true]], true]; + yield 'user info disabled' . $suffix => [$legacy + ['data_collection' => ['user_info' => false]], false]; + } + } + + public function testFactoryPreservesHeaderRestrictions(): void + { + foreach ([[], ['data_collection' => []]] as $configuration) { + $collector = RequestDataCollector::fromOptions(new Options($configuration), ['x-tenant-id']); + $this->assertSame([ + 'X-Tenant-ID' => ['[Filtered]'], + 'X-Test' => ['visible'], + ], $collector->collectHeaders(['X-Tenant-ID' => ['private'], 'X-Test' => ['visible']])); + } + } + public function testUsesDataCollectionDistinguishesConfiguredAndLegacyModes(): void { $this->assertFalse($this->legacyCollector(false)->usesDataCollection()); @@ -99,6 +144,20 @@ public function testCollectCookiesReturnsNullWhenDisabled(): void $this->assertNull($collector->collectCookies(['theme' => 'dark'])); } + public function testCookieAllowListPreservesRepeatedValues(): void + { + $collector = $this->collector(['cookies' => ['mode' => 'allowList', 'terms' => ['theme', 'session']]]); + $this->assertSame([ + 'theme' => ['dark', 'light'], + 'session' => '[Filtered]', + 'language' => '[Filtered]', + ], $collector->collectCookies([ + 'theme' => ['dark', 'light'], + 'session' => ['one', 'two'], + 'language' => ['en', 'de'], + ])); + } + public function testCollectHeadersPreservesLegacyBehaviorWhenPiiIsEnabled(): void { $headers = ['Authorization' => ['secret']]; @@ -152,81 +211,52 @@ public function testCollectHeadersUsesRequestHeaderBehavior(): void ])); } - public function testCollectHeadersReturnsNullWhenRequestHeadersAreDisabled(): void - { - $collector = $this->collector([ - 'http_headers' => [ - 'request' => ['mode' => 'off'], - 'response' => ['mode' => 'denyList'], - ], - ]); - - $this->assertNull($collector->collectHeaders(['X-Request-Id' => ['request-id']])); - } - - public function testShouldCollectRequestBodyPreservesLegacyBehavior(): void - { - $this->assertTrue($this->legacyCollector(false)->shouldCollectRequestBody()); - $this->assertTrue($this->legacyCollector(true)->shouldCollectRequestBody()); - } - - public function testShouldCollectRequestBodyUsesIncomingRequestBodyType(): void + public function testCookieCollectionIsIndependentOfHeaders(): void { - $this->assertTrue($this->collector(['http_bodies' => ['incomingRequest']])->shouldCollectRequestBody()); - $this->assertFalse($this->collector(['http_bodies' => []])->shouldCollectRequestBody()); - $this->assertFalse($this->collector(['http_bodies' => ['outgoingRequest']])->shouldCollectRequestBody()); - } - - public function testCollectRequestBodyPreservesLegacyBehavior(): void - { - $body = ['password' => 'secret']; - - $this->assertSame($body, $this->legacyCollector(false)->collectRequestBody($body)); - $this->assertSame('raw body', $this->legacyCollector(true)->collectRequestBody('raw body')); + foreach (['off', 'denyList', 'allowList'] as $mode) { + $collector = $this->collector([ + 'cookies' => ['mode' => $mode, 'terms' => ['theme']], + 'http_headers' => ['mode' => 'allowList', 'terms' => ['cookie', 'x-test']], + ]); + $this->assertSame(['X-Test' => ['visible']], $collector->collectHeaders([ + 'CoOkIe' => ['theme=dark'], + 'SET-COOKIE' => ['malformed'], + 'X-Test' => ['visible'], + ])); + } + + $collector = $this->collector(['http_headers' => ['mode' => 'off']]); + $this->assertNull($collector->collectHeaders(['Cookie' => ['theme=dark']])); + $this->assertSame(['theme' => 'dark', 'session_id' => '[Filtered]'], $collector->collectCookies([ + 'theme' => 'dark', + 'session_id' => 'secret', + ])); } - public function testCollectRequestBodyFiltersStructuredSensitiveDataRecursively(): void + public function testExplicitHeaderRestrictionsArePreservedWithDataCollection(): void { - $collector = $this->collector(['http_bodies' => ['incomingRequest']]); - + $collector = new RequestDataCollector(new DataCollectionOptions(), true, ['x-tenant-id']); $this->assertSame([ - 'password' => '[Filtered]', - 'user' => [ - 'api_token' => '[Filtered]', - 'name' => 'alice', - ], - ], $collector->collectRequestBody([ - 'password' => 'secret', - 'user' => [ - 'api_token' => 'token', - 'name' => 'alice', - ], + 'X-Tenant-ID' => ['[Filtered]'], + 'X-Forwarded-For' => ['203.0.113.7'], + 'X-Tenant-ID-Label' => ['visible'], + ], $collector->collectHeaders([ + 'X-Tenant-ID' => ['tenant'], + 'X-Forwarded-For' => ['203.0.113.7'], + 'X-Tenant-ID-Label' => ['visible'], ])); } - public function testCollectRequestBodyPreservesScalarLists(): void - { - $collector = $this->collector(['http_bodies' => ['incomingRequest']]); - - $this->assertSame(['secret', 'foo'], $collector->collectRequestBody(['secret', 'foo'])); - } - - public function testCollectRequestBodyFiltersRawData(): void - { - $collector = $this->collector(['http_bodies' => ['incomingRequest']]); - - $this->assertSame('[Filtered]', $collector->collectRequestBody('raw body')); - } - - public function testCollectRequestBodyReturnsNullWhenDisabledOrEmpty(): void + public function testCollectHeadersReturnsNullWhenRequestHeadersAreDisabled(): void { - $disabled = $this->collector(['http_bodies' => []]); - $enabled = $this->collector(['http_bodies' => ['incomingRequest']]); + $collector = $this->collector([ + 'http_headers' => [ + 'request' => ['mode' => 'off'], + 'response' => ['mode' => 'denyList'], + ], + ]); - $this->assertNull($disabled->collectRequestBody('raw body')); - $this->assertNull($enabled->collectRequestBody('')); - $this->assertNull($enabled->collectRequestBody([])); - $this->assertNull($enabled->collectRequestBody(null)); + $this->assertNull($collector->collectHeaders(['X-Request-Id' => ['request-id']])); } /** diff --git a/tests/Integration/RequestIntegrationTest.php b/tests/Integration/RequestIntegrationTest.php index edb23cb78..4eb22e66d 100644 --- a/tests/Integration/RequestIntegrationTest.php +++ b/tests/Integration/RequestIntegrationTest.php @@ -26,12 +26,13 @@ final class RequestIntegrationTest extends TestCase /** * @dataProvider invokeDataProvider */ - public function testInvoke(array $options, ServerRequestInterface $request, array $expectedRequestContextData, ?UserDataBag $initialUser, ?UserDataBag $expectedUser): void + public function testInvoke(array $options, ServerRequestInterface $request, array $expectedRequestContextData, ?UserDataBag $initialUser, ?UserDataBag $expectedUser, array $initialRequest = [], array $integrationOptions = []): void { $event = Event::createEvent(); $event->setUser($initialUser); + $event->setRequest($initialRequest); - $integration = new RequestIntegration($this->createRequestFetcher($request)); + $integration = new RequestIntegration($this->createRequestFetcher($request), $integrationOptions); $integration->setupOnce(); /** @var ClientInterface&MockObject $client */ @@ -65,6 +66,72 @@ public function testInvoke(array $options, ServerRequestInterface $request, arra public static function invokeDataProvider(): iterable { + yield 'explicit header restrictions remain active with data collection' => [ + ['data_collection' => [], 'send_default_pii' => true], + (new ServerRequest('GET', 'https://example.com/')) + ->withHeader('X-Tenant-ID', 'tenant') + ->withHeader('X-Forwarded-For', '203.0.113.7'), + [ + 'url' => 'https://example.com/', + 'method' => 'GET', + 'cookies' => [], + 'headers' => [ + 'Host' => ['example.com'], + 'X-Tenant-ID' => ['[Filtered]'], + 'X-Forwarded-For' => ['203.0.113.7'], + ], + ], + null, + null, + [], + ['pii_sanitize_headers' => ['x-TeNaNt-Id']], + ]; + + foreach ([ + 'legacy' => [], + 'defaults' => ['data_collection' => []], + 'disabled' => ['data_collection' => [ + 'user_info' => false, + 'http_headers' => ['mode' => 'off'], + 'cookies' => ['mode' => 'off'], + 'url_query_params' => ['mode' => 'off'], + 'http_bodies' => [], + ]], + ] as $name => $options) { + foreach ([ + 'values' => [ + 'url' => 'https://manual.example/?token=explicit', + 'query_string' => 'token=explicit', + 'headers' => ['Authorization' => ['explicit']], + 'cookies' => ['session_id' => 'explicit'], + 'data' => ['password' => 'explicit'], + 'env' => ['CUSTOM' => 'explicit'], + 'custom' => 'explicit', + ], + 'empty values' => [ + 'url' => '', + 'query_string' => null, + 'headers' => [], + 'cookies' => null, + 'data' => [], + 'env' => [], + ], + ] as $case => $initialRequest) { + yield 'explicit request ' . $name . ' ' . $case => [ + $options + ['max_request_body_size' => 'always'], + (new ServerRequest('POST', 'https://automatic.example/?token=automatic')) + ->withHeader('Content-Length', '20') + ->withHeader('Authorization', 'automatic') + ->withCookieParams(['session_id' => 'automatic']) + ->withParsedBody(['password' => 'automatic']), + $initialRequest + ['method' => 'POST'], + null, + null, + $initialRequest, + ]; + } + } + yield [ [ 'send_default_pii' => true, @@ -560,6 +627,7 @@ public static function invokeDataProvider(): iterable ]) ->withHeader('Authorization', 'Bearer secret') ->withHeader('Cookie', 'session_id=secret; theme=dark') + ->withHeader('Set-Cookie', 'theme=light') ->withHeader('X-Forwarded-For', '203.0.113.7') ->withHeader('Content-Length', '100') ->withParsedBody([ @@ -583,17 +651,9 @@ public static function invokeDataProvider(): iterable 'headers' => [ 'Host' => ['www.example.com'], 'Authorization' => ['[Filtered]'], - 'Cookie' => ['[Filtered]'], 'X-Forwarded-For' => ['203.0.113.7'], 'Content-Length' => ['100'], ], - 'data' => [ - 'password' => '[Filtered]', - 'user' => [ - 'api_token' => '[Filtered]', - 'name' => 'alice', - ], - ], ], null, UserDataBag::createFromUserIpAddress('127.0.0.1'), diff --git a/tests/Tracing/GuzzleTracingMiddlewareTest.php b/tests/Tracing/GuzzleTracingMiddlewareTest.php index 1b4aab7ee..130622b95 100644 --- a/tests/Tracing/GuzzleTracingMiddlewareTest.php +++ b/tests/Tracing/GuzzleTracingMiddlewareTest.php @@ -7,12 +7,9 @@ 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; @@ -506,27 +503,20 @@ public function testTraceCollectsConfiguredOutgoingHttpData(): void 'http.query' => 'search=hello%20world&password=[Filtered]', ]; $expectedSpanData = [ + 'http.request.header.cookie.session_id' => '[Filtered]', + 'http.request.header.cookie.theme' => 'dark', + 'http.response.header.set_cookie.session_id' => '[Filtered]', + 'http.response.header.set_cookie.theme' => 'light', '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', - ], - 'unkeyed-secret', - ], '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); + $this->assertArrayNotHasKey('http.request.body.data', $spanData); + $this->assertArrayNotHasKey('http.response.body.data', $spanData); foreach ($expectedSharedData as $key => $value) { $this->assertSame($value, $spanData[$key]); $this->assertSame($value, $breadcrumbData[$key]); @@ -535,11 +525,54 @@ public function testTraceCollectsConfiguredOutgoingHttpData(): void $this->assertSame($value, $spanData[$key]); $this->assertArrayNotHasKey($key, $breadcrumbData); } + foreach (['http.request.header.cookie', 'http.response.header.set-cookie'] as $key) { + $this->assertArrayNotHasKey($key, $spanData); + $this->assertArrayNotHasKey($key, $breadcrumbData); + } + $this->assertSame('session_id=request-secret; theme=dark', $request->getHeaderLine('Cookie')); + $this->assertSame([ + 'session_id=response-secret; Path=/; HttpOnly', + 'theme=light; Path=/', + ], $response->getHeader('Set-Cookie')); $this->assertSame($expectedSharedData['url.full'], $breadcrumbData['url']); $this->assertStringNotContainsString('request-secret', json_encode($spanData)); $this->assertStringNotContainsString('response-secret', json_encode($spanData)); } + public function testParsedCookieCollectionIsIndependentOfHeaders(): void + { + foreach ([false, true] as $pii) { + foreach ([null, [], ['cookies' => ['mode' => 'off']]] as $collection) { + $client = $this->createMock(ClientInterface::class); + $client->method('getOptions')->willReturn(new Options([ + 'traces_sample_rate' => 1, + 'send_default_pii' => $pii, + 'data_collection' => $collection === null ? null : $collection + ['http_headers' => ['mode' => 'off']], + ])); + $hub = new Hub($client); + $transaction = $hub->startTransaction(new TransactionContext()); + $hub->setSpan($transaction); + $response = new Response(200, ['Set-Cookie' => ['theme=light; Path=/', 'session_id=secret; HttpOnly']]); + $function = (GuzzleTracingMiddleware::trace($hub))(static function () use ($response): PromiseInterface { + return new FulfilledPromise($response); + }); + $function(new Request('GET', 'https://example.com', ['Cookie' => 'theme=dark; session_id=secret']), [])->wait(); + $data = $this->getHttpSpan($transaction)->getData(); + foreach (['http.request.header.cookie.' => 'dark', 'http.response.header.set_cookie.' => 'light'] as $prefix => $theme) { + if ($collection !== null && ($collection['cookies']['mode'] ?? null) !== 'off') { + $this->assertSame($theme, $data[$prefix . 'theme']); + $this->assertSame('[Filtered]', $data[$prefix . 'session_id']); + } else { + $this->assertArrayNotHasKey($prefix . 'theme', $data); + $this->assertArrayNotHasKey($prefix . 'session_id', $data); + } + } + $this->assertArrayNotHasKey('http.request.header.cookie', $data); + $this->assertArrayNotHasKey('http.response.header.set-cookie', $data); + } + } + } + public function testTracePreservesExplicitSpanData(): void { $client = $this->createMock(ClientInterface::class); @@ -577,159 +610,6 @@ public function testTracePreservesExplicitSpanData(): void $this->assertSame(['application/json'], $data['http.response.header.content-type']); } - 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, ?int $reportedSize): 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); - - $expectedBody = ['value' => str_repeat('a', $bodySize - 12)]; - $rawBody = '{"value":"' . $expectedBody['value'] . '"}'; - $requestBody = FnStream::decorate(Utils::streamFor($rawBody), [ - 'getSize' => static function () use ($reportedSize): ?int { - return $reportedSize; - }, - ]); - $responseBody = FnStream::decorate(Utils::streamFor($rawBody), [ - 'getSize' => static function () use ($reportedSize): ?int { - return $reportedSize; - }, - ]); - $headers = ['Content-Type' => 'application/json', 'Content-Length' => '1']; - $response = new Response(200, $headers, $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', - $headers, - $requestBody - ), []); - $promise->wait(); - - $this->assertSame(0, $requestBody->tell()); - $this->assertSame(0, $responseBody->tell()); - - $spanData = $this->getHttpSpan($transaction)->getData(); - if ($shouldCollect) { - $this->assertSame($expectedBody, $spanData['http.request.body.data']); - $this->assertSame($expectedBody, $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 'unknown size at limit' => [100000, true, null]; - yield 'unknown size over limit' => [100001, false, null]; - yield 'reported size at limit' => [100000, true, 100000]; - yield 'reported size over limit' => [100001, false, 100001]; - yield 'underreported size at limit' => [100000, true, 1]; - yield 'underreported size over limit' => [100001, false, 1]; - } - public function testTraceRespectsDisabledOutgoingHttpDataCollection(): void { $client = $this->createMock(ClientInterface::class); From 16ded297b181a45b12d7017a1e17dc5e64d2c5b0 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Wed, 9 Sep 2026 14:57:06 +0200 Subject: [PATCH 4/4] feat(data-collection): collect structured HTTP bodies through shared helpers --- src/DataCollection/HttpBodyCollector.php | 288 ++++++++++++++++++ src/DataCollection/HttpDataCollector.php | 14 + src/Integration/RequestIntegration.php | 142 +-------- .../DataCollection/HttpBodyCollectorTest.php | 183 +++++++++++ tests/Integration/RequestIntegrationTest.php | 14 + 5 files changed, 503 insertions(+), 138 deletions(-) create mode 100644 src/DataCollection/HttpBodyCollector.php create mode 100644 tests/DataCollection/HttpBodyCollectorTest.php diff --git a/src/DataCollection/HttpBodyCollector.php b/src/DataCollection/HttpBodyCollector.php new file mode 100644 index 000000000..4f69ecca3 --- /dev/null +++ b/src/DataCollection/HttpBodyCollector.php @@ -0,0 +1,288 @@ + 0, + 'small' => self::REQUEST_BODY_SMALL_MAX_CONTENT_LENGTH, + 'medium' => self::REQUEST_BODY_MEDIUM_MAX_CONTENT_LENGTH, + 'always' => \PHP_INT_MAX, + ]; + + private function __construct() + { + } + + public static function getMaxBodyLength(?Options $options, string $bodyType): int + { + $collection = DataCollectionOptions::fromOptions($options); + if ($options === null || $collection === null || !\in_array($bodyType, $collection->getHttpBodies(), true)) { + return 0; + } + + if ($bodyType === 'incomingRequest' || $bodyType === 'outgoingRequest') { + return min(self::MAX_BODY_LENGTH, self::MAX_REQUEST_BODY_SIZE_OPTION_TO_MAX_LENGTH_MAP[$options->getMaxRequestBodySize()] ?? 0); + } + + return self::MAX_BODY_LENGTH; + } + + /** + * @param mixed $body + * + * @return array|string|null Null means omission + */ + public static function collect(?Options $options, string $bodyType, $body, string $contentType = '') + { + $limit = self::getMaxBodyLength($options, $bodyType); + if ($limit === 0 || $body === null || $body === '') { + return null; + } + + if (\is_string($body)) { + if (\strlen($body) > $limit) { + return null; + } + $mediaType = strtolower(trim(explode(';', $contentType, 2)[0])); + if ($mediaType === 'application/json' || preg_match('{^application/[^/;\s]+\+json$}', $mediaType) === 1) { + /** @mago-ignore analysis:mixed-assignment */ + $body = json_decode($body, true, self::JSON_DEPTH); + if (json_last_error() !== \JSON_ERROR_NONE || !\is_array($body)) { + return KeyValueDataFilter::FILTERED_VALUE; + } + } elseif ($mediaType === 'application/x-www-form-urlencoded') { + $body = Query::parse($body); + } else { + return KeyValueDataFilter::FILTERED_VALUE; + } + } elseif (\is_array($body)) { + $body = self::normalize($body, 0); + try { + if (\strlen(JSON::encode($body)) > $limit) { + return null; + } + } catch (JsonException $exception) { + return KeyValueDataFilter::FILTERED_VALUE; + } + } else { + return KeyValueDataFilter::FILTERED_VALUE; + } + + return KeyValueDataFilter::filterKeyValueData($body, ['mode' => 'denyList', 'terms' => []]); + } + + /** + * @param array $body + * + * @return array + */ + private static function normalize(array $body, int $depth): array + { + $normalized = []; + /** @mago-ignore analysis:mixed-assignment */ + foreach ($body as $key => $value) { + if (\is_array($value)) { + $value = $depth >= self::JSON_DEPTH - 2 ? KeyValueDataFilter::FILTERED_VALUE : self::normalize($value, $depth + 1); + } elseif (($value !== null && !\is_scalar($value)) || (\is_float($value) && !is_finite($value))) { + $value = KeyValueDataFilter::FILTERED_VALUE; + } + $normalized[$key] = $value; + } + + return $normalized; + } + + /** + * Collects event request data, preserving the historical behavior in legacy mode. + * New collection only reads seekable streams, restoring their original position. + * + * @return mixed + */ + public static function collectServerRequest(Options $options, ServerRequestInterface $request) + { + if ($options->getDataCollection() === null) { + /** @mago-ignore analysis:mixed-assignment */ + $body = self::captureRequestBody($options, $request); + + return empty($body) ? null : $body; + } + + $limit = self::getMaxBodyLength($options, 'incomingRequest'); + $length = $request->getHeaderLine('Content-Length'); + if ($limit === 0 || (is_numeric($length) && (float) $length > $limit)) { + return null; + } + $body = $request->getParsedBody(); + if ($body !== null) { + return self::collect($options, 'incomingRequest', $body); + } + + $stream = $request->getBody(); + if (!$stream->isReadable() || !$stream->isSeekable()) { + return null; + } + + try { + $position = $stream->tell(); + try { + $stream->rewind(); + $body = ''; + while (\strlen($body) <= $limit && !$stream->eof()) { + $buffer = $stream->read(min(10000, $limit + 1 - \strlen($body))); + if ($buffer === '') { + break; + } + $body .= $buffer; + } + } finally { + $stream->seek($position); + } + } catch (\RuntimeException $exception) { + return null; + } + + return self::collect($options, 'incomingRequest', $body, $request->getHeaderLine('Content-Type')); + } + + /** + * Gets the decoded body of the request, if available. If the Content-Type + * header contains "application/json" then the content is decoded and if + * the parsing fails then the raw data is returned. If there are submitted + * fields or files, all of their information are parsed and returned. + * + * @param Options $options The options of the client + * @param ServerRequestInterface $request The server request + * + * @return mixed + */ + private static function captureRequestBody(Options $options, ServerRequestInterface $request) + { + $maxRequestBodySize = $options->getMaxRequestBodySize(); + $requestBodySize = (int) $request->getHeaderLine('Content-Length'); + + if (!self::isRequestBodySizeWithinReadBounds($requestBodySize, $maxRequestBodySize)) { + return null; + } + + $requestData = $request->getParsedBody(); + $requestData = array_replace( + self::parseUploadedFiles($request->getUploadedFiles()), + \is_array($requestData) ? $requestData : [] + ); + + if (!empty($requestData)) { + return $requestData; + } + + $requestBody = ''; + $maxLength = self::MAX_REQUEST_BODY_SIZE_OPTION_TO_MAX_LENGTH_MAP[$maxRequestBodySize]; + + if ($maxLength > 0) { + $stream = $request->getBody(); + while ($maxLength > 0 && !$stream->eof()) { + if ('' === $buffer = $stream->read(min($maxLength, self::REQUEST_BODY_MEDIUM_MAX_CONTENT_LENGTH))) { + break; + } + $requestBody .= $buffer; + $maxLength -= \strlen($buffer); + } + } + + if ($request->getHeaderLine('Content-Type') === 'application/json') { + try { + return JSON::decode($requestBody); + } catch (JsonException $exception) { + // Fallback to returning the raw data from the request body + } + } + + return $requestBody; + } + + /** + * Create an array with the same structure as $uploadedFiles, but replacing + * each UploadedFileInterface with an array of info. + * + * @param array $uploadedFiles The uploaded files info from a PSR-7 server request + * + * @return array + */ + private static function parseUploadedFiles(array $uploadedFiles): array + { + $result = []; + + /** @mago-ignore analysis:mixed-assignment */ + foreach ($uploadedFiles as $key => $item) { + if ($item instanceof UploadedFileInterface) { + $result[$key] = [ + 'client_filename' => $item->getClientFilename(), + 'client_media_type' => $item->getClientMediaType(), + 'size' => $item->getSize(), + ]; + } elseif (\is_array($item)) { + $result[$key] = self::parseUploadedFiles($item); + } else { + throw new \UnexpectedValueException(\sprintf('Expected either an object implementing the "%s" interface or an array. Got: "%s".', UploadedFileInterface::class, \is_object($item) ? \get_class($item) : \gettype($item))); + } + } + + return $result; + } + + private static function isRequestBodySizeWithinReadBounds(int $requestBodySize, string $maxRequestBodySize): bool + { + if ($requestBodySize <= 0) { + return false; + } + + if ($maxRequestBodySize === 'none' || $maxRequestBodySize === 'never') { + return false; + } + + if ($maxRequestBodySize === 'small' && $requestBodySize > self::REQUEST_BODY_SMALL_MAX_CONTENT_LENGTH) { + return false; + } + + if ($maxRequestBodySize === 'medium' && $requestBodySize > self::REQUEST_BODY_MEDIUM_MAX_CONTENT_LENGTH) { + return false; + } + + return true; + } +} diff --git a/src/DataCollection/HttpDataCollector.php b/src/DataCollection/HttpDataCollector.php index 85b1602fe..3e54cfc58 100644 --- a/src/DataCollection/HttpDataCollector.php +++ b/src/DataCollection/HttpDataCollector.php @@ -5,6 +5,7 @@ namespace Sentry\DataCollection; use GuzzleHttp\Psr7\Uri; +use Sentry\Options; use Sentry\Tracing\Span; /** @@ -13,6 +14,19 @@ */ final class HttpDataCollector { + /** + * @param mixed $body + * + * @return array + */ + public static function collectBodyData(?Options $options, string $bodyType, $body, string $contentType = ''): array + { + $body = HttpBodyCollector::collect($options, $bodyType, $body, $contentType); + $direction = $bodyType === 'incomingRequest' || $bodyType === 'outgoingRequest' ? 'request' : 'response'; + + return $body === null ? [] : ['http.' . $direction . '.body.data' => $body]; + } + private function __construct() { } diff --git a/src/Integration/RequestIntegration.php b/src/Integration/RequestIntegration.php index 2599704bd..2fc7b49d1 100644 --- a/src/Integration/RequestIntegration.php +++ b/src/Integration/RequestIntegration.php @@ -4,18 +4,15 @@ namespace Sentry\Integration; -use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Message\UploadedFileInterface; +use Sentry\DataCollection\HttpBodyCollector; use Sentry\DataCollection\HttpDataCollector; use Sentry\DataCollection\RequestDataCollector; use Sentry\Event; -use Sentry\Exception\JsonException; use Sentry\Options; use Sentry\OptionsResolver; use Sentry\SentrySdk; use Sentry\State\Scope; use Sentry\UserDataBag; -use Sentry\Util\JSON; /** * This integration collects information from the request and attaches them to @@ -25,31 +22,6 @@ */ final class RequestIntegration implements IntegrationInterface { - /** - * This constant represents the size limit in bytes beyond which the body - * of the request is not captured when the `max_request_body_size` option - * is set to `small`. - */ - private const REQUEST_BODY_SMALL_MAX_CONTENT_LENGTH = 10 ** 3; - - /** - * This constant represents the size limit in bytes beyond which the body - * of the request is not captured when the `max_request_body_size` option - * is set to `medium`. - */ - private const REQUEST_BODY_MEDIUM_MAX_CONTENT_LENGTH = 10 ** 4; - - /** - * This constant is a map of maximum allowed sizes for each value of the - * `max_request_body_size` option. - */ - private const MAX_REQUEST_BODY_SIZE_OPTION_TO_MAX_LENGTH_MAP = [ - 'never' => 0, - 'small' => self::REQUEST_BODY_SMALL_MAX_CONTENT_LENGTH, - 'medium' => self::REQUEST_BODY_MEDIUM_MAX_CONTENT_LENGTH, - 'always' => \PHP_INT_MAX, - ]; - /** * @var RequestFetcherInterface PSR-7 request fetcher */ @@ -160,10 +132,9 @@ private function processEvent(Event $event, Options $options): void $requestData['headers'] = $headers; } - // Preserve existing body collection only when using the legacy configuration. - if (!$collector->usesDataCollection() && !\array_key_exists('data', $event->getRequest())) { - $requestBody = $this->captureRequestBody($options, $request); - if (!empty($requestBody)) { + if (!\array_key_exists('data', $event->getRequest())) { + $requestBody = HttpBodyCollector::collectServerRequest($options, $request); + if ($requestBody !== null) { $requestData['data'] = $requestBody; } } @@ -190,111 +161,6 @@ private function addRequestUserInfo(Event $event, array $userData, array &$reque $event->setUser($user); } - /** - * Gets the decoded body of the request, if available. If the Content-Type - * header contains "application/json" then the content is decoded and if - * the parsing fails then the raw data is returned. If there are submitted - * fields or files, all of their information are parsed and returned. - * - * @param Options $options The options of the client - * @param ServerRequestInterface $request The server request - * - * @return mixed - */ - private function captureRequestBody(Options $options, ServerRequestInterface $request) - { - $maxRequestBodySize = $options->getMaxRequestBodySize(); - $requestBodySize = (int) $request->getHeaderLine('Content-Length'); - - if (!$this->isRequestBodySizeWithinReadBounds($requestBodySize, $maxRequestBodySize)) { - return null; - } - - $requestData = $request->getParsedBody(); - $requestData = array_replace( - $this->parseUploadedFiles($request->getUploadedFiles()), - \is_array($requestData) ? $requestData : [] - ); - - if (!empty($requestData)) { - return $requestData; - } - - $requestBody = ''; - $maxLength = self::MAX_REQUEST_BODY_SIZE_OPTION_TO_MAX_LENGTH_MAP[$maxRequestBodySize]; - - if ($maxLength > 0) { - $stream = $request->getBody(); - while ($maxLength > 0 && !$stream->eof()) { - if ('' === $buffer = $stream->read(min($maxLength, self::REQUEST_BODY_MEDIUM_MAX_CONTENT_LENGTH))) { - break; - } - $requestBody .= $buffer; - $maxLength -= \strlen($buffer); - } - } - - if ($request->getHeaderLine('Content-Type') === 'application/json') { - try { - return JSON::decode($requestBody); - } catch (JsonException $exception) { - // Fallback to returning the raw data from the request body - } - } - - return $requestBody; - } - - /** - * Create an array with the same structure as $uploadedFiles, but replacing - * each UploadedFileInterface with an array of info. - * - * @param array $uploadedFiles The uploaded files info from a PSR-7 server request - * - * @return array - */ - private function parseUploadedFiles(array $uploadedFiles): array - { - $result = []; - - foreach ($uploadedFiles as $key => $item) { - if ($item instanceof UploadedFileInterface) { - $result[$key] = [ - 'client_filename' => $item->getClientFilename(), - 'client_media_type' => $item->getClientMediaType(), - 'size' => $item->getSize(), - ]; - } elseif (\is_array($item)) { - $result[$key] = $this->parseUploadedFiles($item); - } else { - throw new \UnexpectedValueException(\sprintf('Expected either an object implementing the "%s" interface or an array. Got: "%s".', UploadedFileInterface::class, \is_object($item) ? \get_class($item) : \gettype($item))); - } - } - - return $result; - } - - private function isRequestBodySizeWithinReadBounds(int $requestBodySize, string $maxRequestBodySize): bool - { - if ($requestBodySize <= 0) { - return false; - } - - if ($maxRequestBodySize === 'none' || $maxRequestBodySize === 'never') { - return false; - } - - if ($maxRequestBodySize === 'small' && $requestBodySize > self::REQUEST_BODY_SMALL_MAX_CONTENT_LENGTH) { - return false; - } - - if ($maxRequestBodySize === 'medium' && $requestBodySize > self::REQUEST_BODY_MEDIUM_MAX_CONTENT_LENGTH) { - return false; - } - - return true; - } - /** * Configures the options of the client. * diff --git a/tests/DataCollection/HttpBodyCollectorTest.php b/tests/DataCollection/HttpBodyCollectorTest.php new file mode 100644 index 000000000..0304d1a02 --- /dev/null +++ b/tests/DataCollection/HttpBodyCollectorTest.php @@ -0,0 +1,183 @@ +assertSame($expected, HttpBodyCollector::collect($this->options(), 'incomingRequest', $body, $type)); + } + + public function bodiesProvider(): \Generator + { + yield 'JSON' => ['{"name":"Alice","profile":{"PASSWORD":"secret"}}', 'application/json', ['name' => 'Alice', 'profile' => ['PASSWORD' => '[Filtered]']]]; + yield 'JSON suffix' => ['{"token":"secret"}', 'Application/problem+json; charset=utf-8', ['token' => '[Filtered]']]; + yield 'form' => ['profile[name]=Alice&profile[password]=secret', 'application/x-www-form-urlencoded; charset=utf-8', ['profile[name]' => 'Alice', 'profile[password]' => '[Filtered]']]; + yield 'form preserves names' => ['user.name=Alice&user+name=Bob&token=secret', 'application/x-www-form-urlencoded', ['user.name' => 'Alice', 'user name' => 'Bob', 'token' => '[Filtered]']]; + yield 'parsed form' => [['name' => 'Alice', 'password' => 'secret'], '', ['name' => 'Alice', 'password' => '[Filtered]']]; + yield 'numeric keys' => [[['token' => 'secret'], 'ok'], '', [['token' => '[Filtered]'], 'ok']]; + yield 'raw empty' => ['', 'application/json', null]; + yield 'absent' => [null, '', null]; + yield 'empty parsed' => [[], '', []]; + yield 'empty object' => ['{}', 'application/json', []]; + yield 'empty array' => ['[]', 'application/json', []]; + yield 'malformed' => ['{bad', 'application/json', '[Filtered]']; + yield 'scalar string' => ['"secret"', 'application/json', '[Filtered]']; + yield 'scalar number' => ['123', 'application/json', '[Filtered]']; + yield 'JSON null' => ['null', 'application/json', '[Filtered]']; + yield 'unsupported' => ['secret', 'text/plain', '[Filtered]']; + yield 'nonapplication suffix' => ['{}', 'text/example+json', '[Filtered]']; + yield 'whitespace' => [' ', 'application/json', '[Filtered]']; + } + + public function testDirectionSelectionAndLegacyPii(): void + { + foreach (DataCollectionOptions::HTTP_BODY_TYPES as $direction) { + foreach ([false, true] as $pii) { + $this->assertSame([], HttpBodyCollector::collect($this->options(['send_default_pii' => $pii]), $direction, [])); + $this->assertNull(HttpBodyCollector::collect(new Options(['send_default_pii' => $pii]), $direction, [])); + foreach (DataCollectionOptions::HTTP_BODY_TYPES as $selected) { + $options = $this->options(['data_collection' => ['http_bodies' => [$selected]]]); + $this->assertSame($direction === $selected ? [] : null, HttpBodyCollector::collect($options, $direction, [])); + } + } + $this->assertNull(HttpBodyCollector::collect($this->options(['data_collection' => ['http_bodies' => []]]), $direction, [])); + $this->assertSame(0, HttpBodyCollector::getMaxBodyLength(null, $direction)); + } + } + + public function testRequestSizeDisableDoesNotDisableResponses(): void + { + foreach (['none', 'never'] as $size) { + $options = $this->options(['max_request_body_size' => $size]); + $this->assertNull(HttpBodyCollector::collect($options, 'incomingRequest', [])); + $this->assertNull(HttpBodyCollector::collect($options, 'outgoingRequest', [])); + $this->assertSame([], HttpBodyCollector::collect($options, 'incomingResponse', [])); + $this->assertSame([], HttpBodyCollector::collect($options, 'outgoingResponse', [])); + } + } + + public function testEverySizeBoundary(): void + { + foreach (DataCollectionOptions::HTTP_BODY_TYPES as $direction) { + foreach (['small' => 1000, 'medium' => 10000, 'always' => 100000] as $size => $limit) { + if (strpos($direction, 'Response') !== false) { + $limit = 100000; + } + $options = $this->options(['max_request_body_size' => $size]); + $this->assertSame($limit, HttpBodyCollector::getMaxBodyLength($options, $direction)); + foreach ([-1, 0, 1] as $delta) { + $body = ['x' => str_repeat('a', $limit - 8 + $delta)]; + $json = json_encode($body); + $this->assertSame($limit + $delta, \strlen($json)); + $expected = $delta > 0 ? null : $body; + $this->assertSame($expected, HttpBodyCollector::collect($options, $direction, $body)); + $this->assertSame($expected, HttpBodyCollector::collect($options, $direction, $json, 'application/json')); + } + } + } + } + + public function testLimitsMeasureBytesBeforeFiltering(): void + { + $options = $this->options(['max_request_body_size' => 'small']); + $this->assertNull(HttpBodyCollector::collect($options, 'incomingRequest', ['password' => str_repeat('é', 500)])); + $this->assertNull(HttpBodyCollector::collect($options, 'incomingRequest', str_repeat('é', 501), 'text/plain')); + } + + public function testNormalizationDoesNotInvokeCallbacks(): void + { + $object = new class implements \JsonSerializable { + public function jsonSerialize(): array + { + throw new \LogicException('Must not serialize'); + } + + public function __toString(): string + { + throw new \LogicException('Must not cast'); + } + }; + $resource = fopen('php://temp', 'w+'); + try { + $body = ['object' => $object, 'resource' => $resource, 'callback' => static function (): void { + throw new \LogicException('Must not call'); + }]; + $this->assertSame(array_fill_keys(array_keys($body), '[Filtered]'), HttpBodyCollector::collect($this->options(), 'incomingRequest', $body)); + } finally { + fclose($resource); + } + } + + public function testRecursiveArraysAreBounded(): void + { + $body = []; + $body['child'] = &$body; + $result = HttpBodyCollector::collect($this->options(), 'incomingRequest', $body); + for ($i = 0; $i < 511; ++$i) { + $this->assertIsArray($result); + $result = $result['child']; + } + $this->assertSame('[Filtered]', $result); + } + + public function testCustomTermsDoNotAffectBodiesAndExplicitAttributesWin(): void + { + $options = $this->options(['data_collection' => ['http_headers' => ['terms' => ['name']], 'cookies' => ['terms' => ['name']]]]); + $this->assertSame(['name' => 'Alice'], HttpBodyCollector::collect($options, 'incomingRequest', ['name' => 'Alice'])); + foreach ([null, [], ['password' => 'explicit']] as $explicit) { + $span = new Span(); + $span->setData(['http.request.body.data' => $explicit]); + HttpDataCollector::setMissingSpanData($span, HttpDataCollector::collectBodyData($options, 'incomingRequest', ['password' => 'secret'])); + $this->assertSame($explicit, $span->getData()['http.request.body.data']); + } + $this->assertSame(['http.response.body.data' => []], HttpDataCollector::collectBodyData($options, 'outgoingResponse', [])); + } + + public function testServerStreamPositionIsRestored(): void + { + $request = new ServerRequest('POST', '/', ['Content-Type' => 'application/json'], '{"name":"Alice","token":"secret"}'); + $request->getBody()->seek(7); + $this->assertSame(['name' => 'Alice', 'token' => '[Filtered]'], HttpBodyCollector::collectServerRequest($this->options(), $request)); + $this->assertSame(7, $request->getBody()->tell()); + $this->assertSame([], HttpBodyCollector::collectServerRequest($this->options(), $request->withParsedBody([]))); + $this->assertNull(HttpBodyCollector::collectServerRequest($this->options(), $request->withBody(new NoSeekStream($request->getBody())))); + } + + public function testServerStreamReadIsBoundedAndRestored(): void + { + $request = new ServerRequest('POST', '/', ['Content-Type' => 'application/json'], str_repeat('a', 1001)); + $request->getBody()->seek(3); + $options = $this->options(['max_request_body_size' => 'small']); + $this->assertNull(HttpBodyCollector::collectServerRequest($options, $request)); + $this->assertSame(3, $request->getBody()->tell()); + $this->assertNull(HttpBodyCollector::collectServerRequest($options, $request->withHeader('Content-Length', '1001'))); + $this->assertSame(3, $request->getBody()->tell()); + } + + /** + * @param array $options + */ + private function options(array $options = []): Options + { + return new Options($options + ['data_collection' => [], 'max_request_body_size' => 'always']); + } +} diff --git a/tests/Integration/RequestIntegrationTest.php b/tests/Integration/RequestIntegrationTest.php index 4eb22e66d..06771ab3a 100644 --- a/tests/Integration/RequestIntegrationTest.php +++ b/tests/Integration/RequestIntegrationTest.php @@ -66,6 +66,19 @@ public function testInvoke(array $options, ServerRequestInterface $request, arra public static function invokeDataProvider(): iterable { + foreach ([null, [], ['password' => 'explicit']] as $explicit) { + foreach ([[], ['http_bodies' => []]] as $collection) { + yield [ + ['data_collection' => $collection, 'max_request_body_size' => 'none'], + new ServerRequest('POST', 'https://example.com', [], str_repeat('x', 100001)), + ['data' => $explicit, 'url' => 'https://example.com', 'method' => 'POST', 'cookies' => [], 'headers' => ['Host' => ['example.com']]], + null, + null, + ['data' => $explicit], + ]; + } + } + yield 'explicit header restrictions remain active with data collection' => [ ['data_collection' => [], 'send_default_pii' => true], (new ServerRequest('GET', 'https://example.com/')) @@ -654,6 +667,7 @@ public static function invokeDataProvider(): iterable 'X-Forwarded-For' => ['203.0.113.7'], 'Content-Length' => ['100'], ], + 'data' => ['password' => '[Filtered]', 'user' => ['api_token' => '[Filtered]', 'name' => 'alice']], ], null, UserDataBag::createFromUserIpAddress('127.0.0.1'),