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 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 new file mode 100644 index 000000000..3e54cfc58 --- /dev/null +++ b/src/DataCollection/HttpDataCollector.php @@ -0,0 +1,287 @@ + + */ + 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() + { + } + + /** + * 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 === '') { + return null; + } + + return $dataCollection === null + ? $queryString + : KeyValueDataFilter::filterQueryString($queryString, $dataCollection->getUrlQueryParams()); + } + + public static function collectUrl(?DataCollectionOptions $dataCollection, string $url, ?string $legacyUrl = null): string + { + if ($dataCollection === null) { + return $legacyUrl ?? $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; + } + + /** + * 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 + */ + private static function collectHeaders(DataCollectionOptions $dataCollection, array $headers, string $direction): array + { + $headerBehavior = $dataCollection->getHttpHeaders()[$direction]; + $prefix = 'http.' . $direction . '.header.'; + $attributes = []; + + $filteredHeaders = KeyValueDataFilter::filterHeaders($headers, $headerBehavior); + foreach ($filteredHeaders ?? [] as $name => $values) { + if ($values !== []) { + $attributes[$prefix . $name] = $values; + } + } + + return $attributes; + } + + /** + * @param array $cookies + * + * @return array + */ + public static function collectRequestCookies(DataCollectionOptions $dataCollection, array $cookies): array + { + return self::collectCookies($dataCollection, $cookies, 'request'); + } + + /** + * @param array $cookies + * + * @return array + */ + public static function collectResponseCookies(DataCollectionOptions $dataCollection, array $cookies): array + { + return self::collectCookies($dataCollection, $cookies, 'response'); + } + + /** + * 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 + */ + 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..31b0b3056 --- /dev/null +++ b/src/DataCollection/HttpHeaderNormalizer.php @@ -0,0 +1,74 @@ + $headers + * + * @return array + */ + 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. + 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; + } + + $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. + $normalized[$name][] = \is_scalar($value) ? (string) $value : KeyValueDataFilter::FILTERED_VALUE; + } + } +} diff --git a/src/DataCollection/KeyValueDataFilter.php b/src/DataCollection/KeyValueDataFilter.php index e5d221837..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,25 +114,24 @@ public static function filterKeyValueData(array $data, array $behavior): ?array } /** - * Filters structured HTTP body data while replacing unkeyed top-level values. + * Applies cookie policy to names, preserving all values of a repeated cookie. * - * @param array $data + * @param array $cookies * - * @return array + * @phpstan-param KeyValueCollectionBehavior $behavior + * + * @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) { - $filtered[] = \is_array($value) - ? self::filterHttpBodyData($value) - : self::FILTERED_VALUE; + foreach ($cookies as $name => $value) { + $filtered[$name] = self::shouldFilterValue((string) $name, $behavior) ? self::FILTERED_VALUE : $value; } return $filtered; @@ -195,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 @@ -202,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 45cd82d67..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 @@ -68,18 +105,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); } /** @@ -93,7 +119,7 @@ public function collectCookies(array $cookies): ?array return $this->sendDefaultPii ? $cookies : null; } - return KeyValueDataFilter::filterKeyValueData( + return KeyValueDataFilter::filterCookies( $cookies, $this->dataCollection->getCookies() ); @@ -107,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); } /** @@ -153,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 befd68e64..2fc7b49d1 100644 --- a/src/Integration/RequestIntegration.php +++ b/src/Integration/RequestIntegration.php @@ -4,17 +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 @@ -24,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 */ @@ -63,6 +36,11 @@ final class RequestIntegration implements IntegrationInterface */ private $options; + /** + * @var bool Whether the application explicitly supplied header restrictions + */ + private $hasConfiguredSanitizeHeaders; + /** * Constructor. * @@ -80,6 +58,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); @@ -116,17 +95,14 @@ 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()); $requestData = [ - 'url' => $collector->usesDataCollection() - ? (string) $request->getUri()->withQuery($queryString ?? '') - : (string) $request->getUri(), + 'url' => HttpDataCollector::collectUrl($options->getDataCollection(), (string) $request->getUri()), 'method' => $request->getMethod(), ]; @@ -134,8 +110,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()); @@ -150,145 +132,35 @@ private function processEvent(Event $event, Options $options): void $requestData['headers'] = $headers; } - if ($collector->shouldCollectRequestBody()) { - $requestBody = $collector->collectRequestBody($this->captureRequestBody($options, $request)); - + if (!\array_key_exists('data', $event->getRequest())) { + $requestBody = HttpBodyCollector::collectServerRequest($options, $request); if ($requestBody !== null) { $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); } - /** - * 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/src/Tracing/GuzzleTracingMiddleware.php b/src/Tracing/GuzzleTracingMiddleware.php index a883dee22..d1d205981 100644 --- a/src/Tracing/GuzzleTracingMiddleware.php +++ b/src/Tracing/GuzzleTracingMiddleware.php @@ -5,19 +5,16 @@ 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\KeyValueDataFilter; +use Sentry\DataCollection\HttpDataCollector; +use Sentry\DataCollection\HttpHeaderNormalizer; use Sentry\Options; use Sentry\SentrySdk; use Sentry\State\HubInterface; -use Sentry\Util\JSON; use function Sentry\getBaggage; use function Sentry\getTraceparent; @@ -27,17 +24,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 { @@ -56,41 +42,34 @@ 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 = self::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(); } - $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; $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( + HttpDataCollector::collectRequestData( $dataCollection, - $sdkOptions->getMaxRequestBodySize(), - $request, - $requestBody + HttpHeaderNormalizer::normalize($request->getHeaders()) ) ); } @@ -118,7 +97,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 +138,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 +154,7 @@ public static function trace(?HubInterface $hub = null): \Closure 'http', null, array_merge([ - 'url' => (string) $collectedUri, + 'url' => $collectedUrl, ], $spanAndBreadcrumbData) )); @@ -187,197 +170,15 @@ 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 - * - * @return array - */ - private static function collectRequestSpanData( - DataCollectionOptions $dataCollection, - string $maxRequestBodySize, - RequestInterface $request, - StreamInterface $body - ): array { - $data = self::collectHeaders($dataCollection, $request->getHeaders(), 'request'); - - if (!\in_array('outgoingRequest', $dataCollection->getHttpBodies(), true)) { - return $data; - } - - $maxBodyLength = self::MAX_REQUEST_BODY_SIZE_TO_LENGTH[$maxRequestBodySize]; - $collectedBody = self::collectBody($body, $request->getHeaderLine('Content-Type'), $maxBodyLength); - - if ($collectedBody !== null) { - $data['http.request.body.data'] = $collectedBody; - } - - return $data; - } - /** * @return array */ - private static function collectResponseSpanData(?DataCollectionOptions $dataCollection, ResponseInterface $response): array + private static function collectResponseSpanData(?DataCollectionOptions $options, ResponseInterface $response): array { - if ($dataCollection === null) { - return []; - } - - $data = self::collectHeaders($dataCollection, $response->getHeaders(), 'response'); - - if (!\in_array('incomingResponse', $dataCollection->getHttpBodies(), true)) { - return $data; - } - - $collectedBody = self::collectBody( - $response->getBody(), - $response->getHeaderLine('Content-Type'), - self::HTTP_BODY_MAX_CONTENT_LENGTH + return HttpDataCollector::collectResponseData( + $options, + HttpHeaderNormalizer::normalize($response->getHeaders()) ); - - if ($collectedBody !== null) { - $data['http.response.body.data'] = $collectedBody; - } - - return $data; - } - - /** - * @param array $headers - * @param 'request'|'response' $direction - * - * @return array - */ - private static function collectHeaders(DataCollectionOptions $dataCollection, array $headers, string $direction): array - { - $headerBehavior = $dataCollection->getHttpHeaders()[$direction]; - $cookieBehavior = $dataCollection->getCookies(); - $prefix = 'http.' . $direction . '.header.'; - $regularHeaders = []; - $attributes = []; - - foreach ($headers as $name => $values) { - $name = strtolower((string) $name); - - if ($name === 'cookie' || $name === 'set-cookie') { - if ($cookieBehavior['mode'] !== 'off' && $values !== []) { - // PSR-7 exposes cookies as raw header strings, so use the safe fallback required by the data collection spec. - $attributes[$prefix . $name] = array_fill(0, \count($values), KeyValueDataFilter::FILTERED_VALUE); - } - - continue; - } - - $regularHeaders[$name] = $values; - } - - $filteredHeaders = KeyValueDataFilter::filterHeaders($regularHeaders, $headerBehavior); - foreach ($filteredHeaders ?? [] as $name => $values) { - $attributes[$prefix . $name] = $values; - } - - return $attributes; - } - - /** - * @return array|string|null - */ - private static function collectBody(StreamInterface $body, string $contentType, int $maxBodyLength) - { - if ($maxBodyLength === 0) { - return null; - } - - $bodySize = $body->getSize(); - if ($bodySize === 0 || ($bodySize !== null && $bodySize > $maxBodyLength)) { - return null; - } - - $mediaType = strtolower(trim(explode(';', $contentType, 2)[0])); - - $isJson = $mediaType === 'application/json' - // RFC 6839 structured syntax suffix, e.g. application/problem+json. - || substr($mediaType, -5) === '+json'; - $isForm = $mediaType === 'application/x-www-form-urlencoded'; - - if (!$isJson && !$isForm) { - return KeyValueDataFilter::FILTERED_VALUE; - } - - // The size can be unknown (a null body size), so readBody() enforces the limit again after reading. - $bodyContents = self::readBody($body, $maxBodyLength); - if ($bodyContents === null) { - return null; - } - - try { - if ($isJson) { - /** @mago-ignore analysis:mixed-assignment */ - $decodedBody = JSON::decode($bodyContents); - } else { - /** @var array $decodedBody */ - $decodedBody = Query::parse($bodyContents); - } - } catch (\Throwable $exception) { - return KeyValueDataFilter::FILTERED_VALUE; - } - - if (!\is_array($decodedBody)) { - return KeyValueDataFilter::FILTERED_VALUE; - } - - return KeyValueDataFilter::filterHttpBodyData($decodedBody); - } - - private static function readBody(StreamInterface $body, int $maxBodyLength): ?string - { - if (!$body->isReadable() || !$body->isSeekable()) { - return null; - } - - $position = null; - - try { - $position = $body->tell(); - $body->rewind(); - - // Read one byte past the limit to detect bodies of unknown size that exceed it. - $contents = Utils::copyToString($body, $maxBodyLength + 1); - - if ($contents === '' || \strlen($contents) > $maxBodyLength) { - return null; - } - - return $contents; - } catch (\Throwable $exception) { - return null; - } finally { - if ($position !== null) { - self::restoreBodyPosition($body, $position); - } - } - } - - private static function restoreBodyPosition(StreamInterface $body, int $position): void - { - try { - $body->seek($position); - } catch (\Throwable $exception) { - // Ignore streams that report themselves as seekable but cannot be restored. - } } private static function shouldAttachTracingHeaders(?Options $options, RequestInterface $request): bool 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 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/DataCollection/HttpDataCollectorTest.php b/tests/DataCollection/HttpDataCollectorTest.php new file mode 100644 index 000000000..7b298644a --- /dev/null +++ b/tests/DataCollection/HttpDataCollectorTest.php @@ -0,0 +1,250 @@ + ['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( + '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 testCollectHeadersFiltersSensitiveHeadersAndExcludesCookies(): void + { + $this->assertSame([ + 'http.request.header.authorization' => ['[Filtered]'], + 'http.request.header.x-request-id' => ['request-id'], + ], 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 + { + $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::collectRequestHeaders($options, $headers)); + $this->assertSame([ + 'http.response.header.x-test' => ['plain'], + 'http.response.header.x-other' => ['[Filtered]'], + 'http.response.header.authorization' => ['[Filtered]'], + ], HttpDataCollector::collectResponseHeaders($options, $headers)); + $options = new DataCollectionOptions(['http_headers' => ['mode' => 'off']]); + $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 + { + $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..18dabbec4 --- /dev/null +++ b/tests/DataCollection/HttpHeaderNormalizerTest.php @@ -0,0 +1,134 @@ +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(['x-removed' => []], 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.authorization' => ['[Filtered]'], + 'http.request.header.x-request-id' => ['request-id'], + ], HttpDataCollector::collectRequestHeaders(new DataCollectionOptions(), $headers)); + } +} diff --git a/tests/DataCollection/KeyValueDataFilterTest.php b/tests/DataCollection/KeyValueDataFilterTest.php index ea56ad62a..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,23 +96,6 @@ public function testFilterKeyValueDataFiltersNestedData(): void ], $filtered); } - public function testFilterHttpBodyDataFiltersSensitiveAndUnkeyedValues(): void - { - $this->assertSame([ - [ - 'password' => '[Filtered]', - 'name' => 'alice', - ], - '[Filtered]', - ], KeyValueDataFilter::filterHttpBodyData([ - [ - 'password' => 'secret', - 'name' => 'alice', - ], - 'unkeyed secret', - ])); - } - public function testFilterHeadersReturnsNullWhenCollectionIsOff(): void { $behavior = ['mode' => 'off', 'terms' => ['x-request-id']]; @@ -138,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); } @@ -158,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'], @@ -173,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'], @@ -186,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 d17ec4bc2..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,74 +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 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 e6e723a59..06771ab3a 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,85 @@ 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/')) + ->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, @@ -531,7 +611,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,13 +633,14 @@ 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', ]) ->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([ @@ -570,7 +651,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' => [ @@ -583,17 +664,10 @@ 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', - ], - ], + '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 f8c2106c2..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; @@ -491,7 +488,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 */ @@ -502,31 +499,24 @@ 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 = [ + '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', - ], - '[Filtered]', - ], 'http.response.header.content-type' => ['application/x-www-form-urlencoded'], 'http.response.header.x-response-id' => ['response-123'], - 'http.response.header.set-cookie' => ['[Filtered]', '[Filtered]'], - 'http.response.body.data' => [ - 'token' => '[Filtered]', - 'status' => 'ok', - ], ]; $spanData = $this->getHttpSpan($transaction)->getData(); $breadcrumbData = $this->getBreadcrumbData($hub); + $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,156 +525,89 @@ 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 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 + public function testParsedCookieCollectionIsIndependentOfHeaders(): 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); + 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); + } + } } - /** - * @dataProvider httpBodySafetyLimitDataProvider - */ - public function testTraceAppliesHttpBodySafetyLimit(int $bodySize, bool $shouldCollect): void + public function testTracePreservesExplicitSpanData(): 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' => [], - ])); - + $client->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); - $rawBody = str_repeat('a', $bodySize); - $requestBody = FnStream::decorate(Utils::streamFor($rawBody), [ - 'getSize' => static function (): ?int { - return null; - }, - ]); - $responseBody = FnStream::decorate(Utils::streamFor($rawBody), [ - 'getSize' => static function (): ?int { - return null; - }, - ]); - $response = new Response(200, ['Content-Type' => 'application/json'], $responseBody); $middleware = GuzzleTracingMiddleware::trace($hub); - $function = $middleware(static function () use ($response): PromiseInterface { - return new FulfilledPromise($response); + $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( - 'POST', - 'https://www.example.com', - ['Content-Type' => 'application/json'], - $requestBody - ), []); + $promise = $function(new Request('GET', 'https://www.example.com/?token=secret'), []); $promise->wait(); - $this->assertSame(0, $requestBody->tell()); - $this->assertSame(0, $responseBody->tell()); - - $spanData = $this->getHttpSpan($transaction)->getData(); - if ($shouldCollect) { - $this->assertSame('[Filtered]', $spanData['http.request.body.data']); - $this->assertSame('[Filtered]', $spanData['http.response.body.data']); - } else { - $this->assertArrayNotHasKey('http.request.body.data', $spanData); - $this->assertArrayNotHasKey('http.response.body.data', $spanData); - } - } - - public static function httpBodySafetyLimitDataProvider(): iterable - { - yield 'at 100 KB safety limit' => [100000, true]; - yield 'over 100 KB safety limit' => [100001, false]; + $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 testTraceRespectsDisabledOutgoingHttpDataCollection(): void