diff --git a/src/DataCollection/DataCollectionOptions.php b/src/DataCollection/DataCollectionOptions.php index 62f60f4b3..a8fef96f6 100644 --- a/src/DataCollection/DataCollectionOptions.php +++ b/src/DataCollection/DataCollectionOptions.php @@ -22,15 +22,8 @@ * stack_frame_variables: KeyValueCollectionBehavior, * frame_context_lines: int * } - * - * @phpstan-implements \ArrayAccess< - * key-of, - * value-of - * > - * - * @mago-ignore analysis:missing-template-parameter */ -final class DataCollectionOptions implements \ArrayAccess +final class DataCollectionOptions { private const COLLECTION_MODES = [ 'off', @@ -43,14 +36,16 @@ final class DataCollectionOptions implements \ArrayAccess 'terms' => [], ]; - /** - * @internal - */ + public const HTTP_BODY_INCOMING_REQUEST = 'incomingRequest'; + public const HTTP_BODY_OUTGOING_REQUEST = 'outgoingRequest'; + public const HTTP_BODY_INCOMING_RESPONSE = 'incomingResponse'; + public const HTTP_BODY_OUTGOING_RESPONSE = 'outgoingResponse'; + public const HTTP_BODY_TYPES = [ - 'incomingRequest', - 'outgoingRequest', - 'incomingResponse', - 'outgoingResponse', + self::HTTP_BODY_INCOMING_REQUEST, + self::HTTP_BODY_OUTGOING_REQUEST, + self::HTTP_BODY_INCOMING_RESPONSE, + self::HTTP_BODY_OUTGOING_RESPONSE, ]; private const DEFAULTS = [ @@ -253,66 +248,6 @@ public function setFrameContextLines(int $frameContextLines): self return $this->updateOptions(['frame_context_lines' => $frameContextLines]); } - /** - * @param mixed $offset - */ - public function offsetExists($offset): bool - { - return \is_string($offset) && \array_key_exists($offset, $this->options); - } - - /** - * @phpstan-template TKey of key-of - * - * @param mixed $offset - * - * @phpstan-param TKey $offset - * - * @return mixed - * - * @phpstan-return ResolvedDataCollectionOptions[TKey] - * - * @mago-ignore analysis:incompatible-parameter-type - * @mago-ignore analysis:invalid-return-statement - */ - #[\ReturnTypeWillChange] - public function offsetGet($offset) - { - if (!$this->offsetExists($offset)) { - /** @phpstan-ignore-next-line Runtime access to unknown offsets is intentionally non-throwing. */ - return null; - } - - return $this->options[$offset]; - } - - /** - * @param mixed $offset - * @param mixed $value - */ - public function offsetSet($offset, $value): void - { - if (!\is_string($offset)) { - return; - } - - $this->updateOptions([$offset => $value]); - } - - /** - * @param mixed $offset - */ - public function offsetUnset($offset): void - { - if (!\is_string($offset) || !\array_key_exists($offset, self::DEFAULTS)) { - return; - } - - /** @var mixed $default */ - $default = self::DEFAULTS[$offset]; - $this->updateOptions([$offset => $default]); - } - private function configureOptions(OptionsResolver $resolver): void { $resolver->setAllowedTypes('user_info', 'bool'); diff --git a/src/DataCollection/DataCollectionPolicy.php b/src/DataCollection/DataCollectionPolicy.php new file mode 100644 index 000000000..7ad68eed2 --- /dev/null +++ b/src/DataCollection/DataCollectionPolicy.php @@ -0,0 +1,69 @@ +options = $options; + } + + public static function fromHub(HubInterface $hub): self + { + $client = $hub->getClient(); + + return new self($client === null ? null : $client->getOptions()); + } + + public static function fromOptions(?Options $options): self + { + return new self($options); + } + + public function getOptions(): ?Options + { + return $this->options; + } + + public function getDataCollection(): ?DataCollectionOptions + { + return $this->options === null ? null : $this->options->getDataCollection(); + } + + public function isLegacyMode(): bool + { + return $this->getDataCollection() === null; + } + + public function shouldCollectUserInfo(): bool + { + $dataCollection = $this->getDataCollection(); + + if ($dataCollection !== null) { + return $dataCollection->shouldCollectUserInfo(); + } + + return $this->options !== null && $this->options->shouldSendDefaultPii(); + } +} diff --git a/src/DataCollection/HttpBodyCollector.php b/src/DataCollection/HttpBodyCollector.php new file mode 100644 index 000000000..5e4955346 --- /dev/null +++ b/src/DataCollection/HttpBodyCollector.php @@ -0,0 +1,296 @@ + 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(DataCollectionPolicy $policy, string $bodyType): int + { + $options = $policy->getOptions(); + $collection = $policy->getDataCollection(); + if ($options === null || $collection === null || !\in_array($bodyType, $collection->getHttpBodies(), true)) { + return 0; + } + + if ($bodyType === DataCollectionOptions::HTTP_BODY_INCOMING_REQUEST || $bodyType === DataCollectionOptions::HTTP_BODY_OUTGOING_REQUEST) { + return min(self::MAX_BODY_LENGTH, self::MAX_REQUEST_BODY_SIZE_OPTION_TO_MAX_LENGTH_MAP[$options->getMaxRequestBodySize()] ?? 0); + } + + return self::MAX_BODY_LENGTH; + } + + /** + * Byte limits apply to raw strings. Integrations supplying parsed arrays + * should check the original body length when available; arrays are never + * serialized just to measure their size. + * + * @param mixed $body + * + * @return array|string|null Null means omission + */ + public static function collect(DataCollectionPolicy $policy, string $bodyType, $body, string $contentType = '') + { + $limit = self::getMaxBodyLength($policy, $bodyType); + if ($limit === 0) { + return null; + } + + if (\is_array($body)) { + $body = self::normalize($body, 0); + } elseif (\is_string($body)) { + if ($body === '' || \strlen($body) > $limit) { + return null; + } + + $mediaType = strtolower(trim(explode(';', $contentType, 2)[0])); + if ($mediaType === 'application/json' || preg_match('{^application/[^/;\s]+\+json$}', $mediaType) === 1) { + try { + /** @mago-ignore analysis:mixed-assignment */ + $body = JSON::decode($body); + } catch (JsonException $exception) { + return KeyValueDataFilter::FILTERED_VALUE; + } + if (!\is_array($body)) { + return KeyValueDataFilter::FILTERED_VALUE; + } + } elseif ($mediaType === 'application/x-www-form-urlencoded') { + $body = Query::parse($body); + } else { + return KeyValueDataFilter::FILTERED_VALUE; + } + } else { + return null; + } + + 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(DataCollectionPolicy $policy, ServerRequestInterface $request) + { + $options = $policy->getOptions(); + if ($options === null) { + return null; + } + + if ($policy->isLegacyMode()) { + /** @mago-ignore analysis:mixed-assignment */ + $body = self::captureRequestBody($options, $request); + + return empty($body) ? null : $body; + } + + $limit = self::getMaxBodyLength($policy, DataCollectionOptions::HTTP_BODY_INCOMING_REQUEST); + $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($policy, DataCollectionOptions::HTTP_BODY_INCOMING_REQUEST, $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($policy, DataCollectionOptions::HTTP_BODY_INCOMING_REQUEST, $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..f44fade7a --- /dev/null +++ b/src/DataCollection/HttpDataCollector.php @@ -0,0 +1,286 @@ + + */ + public static function collectBodyData(DataCollectionPolicy $policy, string $bodyType, $body, string $contentType = ''): array + { + $body = HttpBodyCollector::collect($policy, $bodyType, $body, $contentType); + $direction = $bodyType === DataCollectionOptions::HTTP_BODY_INCOMING_REQUEST || $bodyType === DataCollectionOptions::HTTP_BODY_OUTGOING_REQUEST ? '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(DataCollectionPolicy $policy, string $queryString): array + { + $queryString = self::collectQueryString($policy, $queryString); + + return $queryString === null ? [] : ['http.query' => $queryString]; + } + + public static function collectQueryString(DataCollectionPolicy $policy, string $queryString): ?string + { + if ($queryString === '') { + return null; + } + + $dataCollection = $policy->getDataCollection(); + + return $dataCollection === null + ? $queryString + : KeyValueDataFilter::filterQueryString($queryString, $dataCollection->getUrlQueryParams()); + } + + public static function collectUrl(DataCollectionPolicy $policy, string $url, ?string $legacyUrl = null): string + { + if ($policy->isLegacyMode()) { + return $legacyUrl ?? $url; + } + + $uri = new Uri($url); + $query = self::collectQueryString($policy, (string) parse_url($url, \PHP_URL_QUERY)); + $result = (string) $uri->withUserInfo('')->withQuery('')->withFragment(''); + + if ($query !== null && $query !== '') { + $result .= '?' . $query; + } + + 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(DataCollectionPolicy $policy, array $headers, ?array $cookies = null): array + { + $dataCollection = $policy->getDataCollection(); + if ($dataCollection === null) { + return []; + } + + $data = self::collectRequestHeaders($dataCollection, $headers); + if ($dataCollection->getCookies()['mode'] !== 'off') { + $malformed = false; + $parsedCookies = self::parseCookies($headers['cookie'] ?? [], false, $malformed); + $data = array_merge($data, self::collectRequestCookies($dataCollection, $cookies ?? $parsedCookies)); + if ($malformed) { + $data['http.request.header.cookie'] = KeyValueDataFilter::FILTERED_VALUE; + } + } + + 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(DataCollectionPolicy $policy, array $headers, ?iterable $cookies = null): array + { + $dataCollection = $policy->getDataCollection(); + if ($dataCollection === null) { + return []; + } + + $data = self::collectResponseHeaders($dataCollection, $headers); + if ($dataCollection->getCookies()['mode'] !== 'off') { + $malformed = false; + $parsedCookies = self::parseCookies($headers['set-cookie'] ?? [], true, $malformed); + $cookies = $cookies === null ? $parsedCookies : self::groupCookieValues($cookies); + $data = array_merge($data, self::collectResponseCookies($dataCollection, $cookies)); + if ($malformed) { + $data['http.response.header.set_cookie'] = KeyValueDataFilter::FILTERED_VALUE; + } + } + + 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, ?bool &$malformed = null): array + { + return self::parseCookies($headers, false, $malformed); + } + + /** + * @param string[] $headers Set-Cookie header values + * + * @return array + */ + public static function parseResponseCookies(array $headers, ?bool &$malformed = null): array + { + return self::parseCookies($headers, true, $malformed); + } + + /** + * @param string[] $headers + * + * @return array + */ + private static function parseCookies(array $headers, bool $response, ?bool &$malformed = null): array + { + $malformed = false; + $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]) === '') { + $malformed = true; + 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; + } +} 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..2eead87b0 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; @@ -177,10 +171,10 @@ private static function shouldFilterValue(string $key, array $behavior): bool } if ($behavior['mode'] === 'allowList') { - return !self::matchesAnyTerm($key, $behavior['terms']); + return !self::matchesAnyTerm($key, $behavior['terms'], false); } - return self::matchesAnyTerm($key, $behavior['terms']); + return self::matchesAnyTerm($key, $behavior['terms'], true); } private static function matchesMandatoryDenyList(string $key): bool @@ -197,12 +191,16 @@ private static function matchesMandatoryDenyList(string $key): bool /** * @param string[] $terms */ - private static function matchesAnyTerm(string $key, array $terms): bool + private static function matchesAnyTerm(string $key, array $terms, bool $partial): bool { $key = strtolower($key); foreach ($terms as $term) { - if (strpos($key, strtolower($term)) !== false) { + $term = strtolower($term); + if ($term === '') { + continue; + } + if ($partial ? strpos($key, $term) !== false : $key === $term) { return true; } } diff --git a/src/DataCollection/RequestDataCollector.php b/src/DataCollection/RequestDataCollector.php index 45cd82d67..92fceaa4d 100644 --- a/src/DataCollection/RequestDataCollector.php +++ b/src/DataCollection/RequestDataCollector.php @@ -5,7 +5,10 @@ namespace Sentry\DataCollection; /** - * @internal + * Collects event request data while preserving the temporary legacy mode. + * + * This is shared infrastructure for first-party SDK integrations. It is + * public in PHP terms so framework SDKs can reuse the same behavior. */ final class RequestDataCollector { @@ -23,63 +26,56 @@ final class RequestDataCollector ]; /** - * @var DataCollectionOptions|null + * @var DataCollectionPolicy */ - private $dataCollection; + private $policy; /** - * @var bool - */ - private $sendDefaultPii; - - /** - * @var string[] + * @var string[]|null */ private $piiSanitizeHeaders; /** - * @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 - ) { - $this->dataCollection = $dataCollection; - $this->sendDefaultPii = $sendDefaultPii; + public function __construct(DataCollectionPolicy $policy, ?array $piiSanitizeHeaders = null) + { + $this->policy = $policy; $this->piiSanitizeHeaders = $piiSanitizeHeaders; } - public function usesDataCollection(): bool + /** + * @template T + * + * @param array $data + * + * @return array + */ + public function collectUserInfo(array $data): array { - return $this->dataCollection !== null; + return $this->policy->shouldCollectUserInfo() ? $data : []; } - public function shouldCollectUserInfo(): bool + /** + * @return array + */ + public function collectClientIpData(?string $ipAddress): array { - if ($this->dataCollection === null) { - return $this->sendDefaultPii; + if ($ipAddress === null || !$this->policy->shouldCollectUserInfo()) { + return []; } - return $this->dataCollection->shouldCollectUserInfo(); + return ['net.peer.ip' => $ipAddress]; } - public function collectQueryString(string $queryString): ?string + public function shouldCollectUserInfo(): bool { - if ($this->dataCollection === null) { - return $queryString !== '' ? $queryString : null; - } - - if ($queryString === '') { - return null; - } + return $this->policy->shouldCollectUserInfo(); + } - return KeyValueDataFilter::filterQueryString( - $queryString, - $this->dataCollection->getUrlQueryParams() - ); + public function collectQueryString(string $queryString): ?string + { + return HttpDataCollector::collectQueryString($this->policy, $queryString); } /** @@ -89,63 +85,49 @@ public function collectQueryString(string $queryString): ?string */ public function collectCookies(array $cookies): ?array { - if ($this->dataCollection === null) { - return $this->sendDefaultPii ? $cookies : null; + $dataCollection = $this->policy->getDataCollection(); + if ($dataCollection === null) { + return $this->policy->shouldCollectUserInfo() ? $cookies : null; } - return KeyValueDataFilter::filterKeyValueData( - $cookies, - $this->dataCollection->getCookies() - ); + return KeyValueDataFilter::filterCookies($cookies, $dataCollection->getCookies()); } /** - * @param array $headers + * Returns the safe fallback required when a raw Cookie header cannot be parsed. * - * @return array|null + * @param string[] $cookieHeaders + * + * @return array */ - public function collectHeaders(array $headers): ?array + public function collectMalformedCookieHeader(array $cookieHeaders): array { - if ($this->dataCollection === null) { - return $this->sendDefaultPii ? $headers : $this->sanitizeLegacyHeaders($headers); + $dataCollection = $this->policy->getDataCollection(); + if ($dataCollection === null || $dataCollection->getCookies()['mode'] === 'off') { + return []; } - return KeyValueDataFilter::filterHeaders( - $headers, - $this->dataCollection->getHttpHeaders()['request'] - ); - } + $malformed = false; + HttpDataCollector::parseRequestCookies($cookieHeaders, $malformed); - 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); + return $malformed ? ['Cookie' => [KeyValueDataFilter::FILTERED_VALUE]] : []; } /** - * @param mixed $body + * @param array $headers * - * @return mixed + * @return array|null */ - public function collectRequestBody($body) + public function collectHeaders(array $headers): ?array { - if (empty($body) || !$this->shouldCollectRequestBody()) { - return null; - } - - if ($this->dataCollection === null) { - return $body; + $dataCollection = $this->policy->getDataCollection(); + if ($dataCollection === null) { + return $this->policy->shouldCollectUserInfo() ? $headers : $this->sanitizeHeaders($headers); } - if (!\is_array($body)) { - return KeyValueDataFilter::FILTERED_VALUE; - } + $headers = KeyValueDataFilter::filterHeaders($headers, $dataCollection->getHttpHeaders()['request']); - return KeyValueDataFilter::filterHttpBodyData($body); + return $headers === null ? null : $this->sanitizeHeaders($headers); } /** @@ -153,14 +135,15 @@ public function collectRequestBody($body) * * @return array */ - private function sanitizeLegacyHeaders(array $headers): array + private function sanitizeHeaders(array $headers): array { $sanitized = []; + $restrictedHeaders = $this->piiSanitizeHeaders ?? ($this->policy->isLegacyMode() ? self::DEFAULT_PII_SANITIZE_HEADERS : []); foreach ($headers as $name => $values) { $name = (string) $name; - if (\in_array(strtolower($name), $this->piiSanitizeHeaders, true)) { + if (\in_array(strtolower($name), $restrictedHeaders, true)) { foreach ($values as $headerLine => $headerValue) { $values[$headerLine] = KeyValueDataFilter::FILTERED_VALUE; } diff --git a/src/Integration/RequestIntegration.php b/src/Integration/RequestIntegration.php index befd68e64..8d42f6299 100644 --- a/src/Integration/RequestIntegration.php +++ b/src/Integration/RequestIntegration.php @@ -4,17 +4,16 @@ namespace Sentry\Integration; -use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Message\UploadedFileInterface; +use Sentry\DataCollection\DataCollectionPolicy; +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 +23,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 +37,11 @@ final class RequestIntegration implements IntegrationInterface */ private $options; + /** + * @var bool Whether the application explicitly supplied header restrictions + */ + private $hasConfiguredSanitizeHeaders; + /** * Constructor. * @@ -80,6 +59,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 +96,15 @@ private function processEvent(Event $event, Options $options): void return; } + $policy = DataCollectionPolicy::fromOptions($options); $collector = new RequestDataCollector( - $options->getDataCollection(), - $options->shouldSendDefaultPii(), - $this->options['pii_sanitize_headers'] + $policy, + $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($policy, (string) $request->getUri()), 'method' => $request->getMethod(), ]; @@ -134,8 +112,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()); @@ -145,150 +129,41 @@ private function processEvent(Event $event, Options $options): void } $headers = $collector->collectHeaders($request->getHeaders()); + $cookieFallback = $collector->collectMalformedCookieHeader($request->getHeader('Cookie')); - if ($headers !== null) { - $requestData['headers'] = $headers; + if ($headers !== null || $cookieFallback !== []) { + $requestData['headers'] = ($headers ?? []) + $cookieFallback; } - if ($collector->shouldCollectRequestBody()) { - $requestBody = $collector->collectRequestBody($this->captureRequestBody($options, $request)); - + if (!\array_key_exists('data', $event->getRequest())) { + $requestBody = HttpBodyCollector::collectServerRequest($policy, $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..c4c5e2b96 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\DataCollectionPolicy; +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; + $policy = DataCollectionPolicy::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($policy, $requestUri->getQuery()); if ($requestUri->getFragment() !== '') { $spanAndBreadcrumbData['http.fragment'] = $requestUri->getFragment(); } - $collectedUri = $partialUri; - if ($dataCollection !== null) { - $collectedUri = $collectedUri - ->withQuery($queryString ?? '') - ->withFragment($requestUri->getFragment()); - $spanAndBreadcrumbData['url.full'] = (string) $collectedUri; + $collectedUrl = (string) $partialUri; + if (!$policy->isLegacyMode()) { + $collectedUrl = HttpDataCollector::collectUrl($policy, (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 (!$policy->isLegacyMode()) { + // 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( - $dataCollection, - $sdkOptions->getMaxRequestBodySize(), - $request, - $requestBody + HttpDataCollector::collectRequestData( + $policy, + 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, $policy) { 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 @@ -156,10 +135,14 @@ public static function trace(?HubInterface $hub = null): \Closure $spanData = array_merge( $spanData, $spanAndBreadcrumbData, - self::collectResponseSpanData($dataCollection, $response) + self::collectResponseSpanData($policy, $response) ); $childSpan->setStatus(SpanStatus::createFromHttpStatusCode($response->getStatusCode())); - $childSpan->setData($spanData); + if ($policy->isLegacyMode()) { + $childSpan->setData($spanData); + } else { + $childSpan->setData(array_diff_key($spanData, $childSpan->getData())); + } } 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(DataCollectionPolicy $policy, 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( + $policy, + 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..843984fb5 100644 --- a/tests/DataCollection/DataCollectionOptionsTest.php +++ b/tests/DataCollection/DataCollectionOptionsTest.php @@ -16,10 +16,7 @@ public function testDefaults(): void $this->assertTrue($options->shouldCollectUserInfo()); $this->assertSame($collectionDefault, $options->getCookies()); - $this->assertSame([ - 'request' => $collectionDefault, - 'response' => $collectionDefault, - ], $options->getHttpHeaders()); + $this->assertSame(['request' => $collectionDefault, 'response' => $collectionDefault], $options->getHttpHeaders()); $this->assertSame(DataCollectionOptions::HTTP_BODY_TYPES, $options->getHttpBodies()); $this->assertSame($collectionDefault, $options->getUrlQueryParams()); $this->assertSame(['inputs' => true, 'outputs' => true], $options->getGenAi()); @@ -33,17 +30,14 @@ public function testDefaults(): void public function testSharedHttpHeadersConfigurationAppliesToBothDirections(): void { $options = new DataCollectionOptions([ - 'http_headers' => [ - 'mode' => 'allowList', - 'terms' => ['x-request-id'], - ], + 'http_headers' => ['mode' => 'allowList', 'terms' => ['x-request-id']], ]); - $expected = ['mode' => 'allowList', 'terms' => ['x-request-id']]; + $this->assertSame(['request' => $expected, 'response' => $expected], $options->getHttpHeaders()); } - public function testSetterPreservesUnchangedNestedValues(): void + public function testCookieSetterPreservesUnchangedNestedValues(): void { $options = new DataCollectionOptions([ 'cookies' => ['mode' => 'allowList', 'terms' => ['first']], @@ -55,134 +49,179 @@ public function testSetterPreservesUnchangedNestedValues(): void $this->assertSame(['mode' => 'allowList', 'terms' => ['second']], $options->getCookies()); } - public function testNullHttpBodiesUsesDefault(): void + public function testUserInfoSetter(): void { - $options = new DataCollectionOptions(['http_bodies' => null]); + $options = new DataCollectionOptions(); - $this->assertSame(DataCollectionOptions::HTTP_BODY_TYPES, $options->getHttpBodies()); + $this->assertSame($options, $options->setUserInfo(false)); + $this->assertFalse($options->shouldCollectUserInfo()); } - public function testStackFrameVariablesSupportsBooleanAndKeyValueCollectionBehavior(): void + public function testCookieSetter(): void { - $options = new DataCollectionOptions([ - 'stack_frame_variables' => [ - 'mode' => 'allowList', - 'terms' => ['request_id'], - ], - ]); + $options = new DataCollectionOptions(); - $this->assertSame([ - 'mode' => 'allowList', - 'terms' => ['request_id'], - ], $options->getStackFrameVariables()); - $this->assertTrue($options->shouldCollectStackFrameVariables()); + $this->assertSame($options, $options->setCookies(['mode' => 'off'])); + $this->assertSame(['mode' => 'off', 'terms' => []], $options->getCookies()); + } - $options->setStackFrameVariables(['terms' => ['trace_id']]); - $this->assertSame([ - 'mode' => 'allowList', - 'terms' => ['trace_id'], - ], $options->getStackFrameVariables()); + public function testHttpHeaderSetter(): void + { + $options = new DataCollectionOptions(); - $options->setStackFrameVariables(false); - $this->assertSame(['mode' => 'off', 'terms' => []], $options->getStackFrameVariables()); - $this->assertFalse($options->shouldCollectStackFrameVariables()); + $this->assertSame($options, $options->setHttpHeaders(['request' => ['mode' => 'off']])); + $this->assertSame('off', $options->getHttpHeaders()['request']['mode']); + $this->assertSame('denyList', $options->getHttpHeaders()['response']['mode']); + } - $options->setStackFrameVariables(true); - $this->assertSame(['mode' => 'denyList', 'terms' => []], $options->getStackFrameVariables()); - $this->assertTrue($options->shouldCollectStackFrameVariables()); + public function testHttpBodySetter(): void + { + $options = new DataCollectionOptions(); + + $this->assertSame($options, $options->setHttpBodies([])); + $this->assertSame([], $options->getHttpBodies()); + } + + public function testUrlQueryParameterSetter(): void + { + $options = new DataCollectionOptions(); + + $this->assertSame($options, $options->setUrlQueryParams(['mode' => 'allowList', 'terms' => ['page']])); + $this->assertSame(['mode' => 'allowList', 'terms' => ['page']], $options->getUrlQueryParams()); + } + + public function testGenAiSetter(): void + { + $options = new DataCollectionOptions(); + + $this->assertSame($options, $options->setGenAi(['inputs' => false])); + $this->assertSame(['inputs' => false, 'outputs' => true], $options->getGenAi()); + } + + public function testDatabaseQueryDataSetter(): void + { + $options = new DataCollectionOptions(); + + $this->assertSame($options, $options->setDatabaseQueryData(false)); + $this->assertFalse($options->shouldCollectDatabaseQueryData()); + } - $options->setStackFrameVariables(['mode' => 'off']); - $this->assertSame(['mode' => 'off', 'terms' => []], $options->getStackFrameVariables()); + public function testQueueSetter(): void + { + $options = new DataCollectionOptions(); + + $this->assertSame($options, $options->setQueues(false)); + $this->assertFalse($options->shouldCollectQueues()); + } + + public function testStackFrameVariableSetter(): void + { + $options = new DataCollectionOptions(); + + $this->assertSame($options, $options->setStackFrameVariables(false)); $this->assertFalse($options->shouldCollectStackFrameVariables()); } - public function testInvalidValuesUseDefaultsAndSettersKeepCurrentValues(): void + public function testFrameContextLineSetter(): void { - $options = new DataCollectionOptions([ - 'cookies' => ['mode' => 'invalid', 'terms' => [42]], - 'http_bodies' => ['invalid'], - 'gen_ai' => ['inputs' => 'invalid'], - 'database_query_data' => 'invalid', - 'queues' => 'invalid', - 'stack_frame_variables' => ['mode' => 'invalid'], - 'frame_context_lines' => -1, - ]); + $options = new DataCollectionOptions(); + + $this->assertSame($options, $options->setFrameContextLines(0)); + $this->assertSame(0, $options->getFrameContextLines()); + } + + public function testNullHttpBodiesUsesDefault(): void + { + $options = new DataCollectionOptions(['http_bodies' => null]); - $this->assertSame(['mode' => 'denyList', 'terms' => []], $options->getCookies()); $this->assertSame(DataCollectionOptions::HTTP_BODY_TYPES, $options->getHttpBodies()); - $this->assertSame(['inputs' => true, 'outputs' => true], $options->getGenAi()); - $this->assertTrue($options->shouldCollectDatabaseQueryData()); - $this->assertTrue($options->shouldCollectQueues()); - $this->assertSame(['mode' => 'denyList', 'terms' => []], $options->getStackFrameVariables()); - $this->assertSame(5, $options->getFrameContextLines()); + } - $options->setCookies(['mode' => 'allowList'])->setCookies(['mode' => 'invalid']); - $options->setHttpBodies(['incomingRequest'])->setHttpBodies(['invalid']); - $options->setStackFrameVariables(['mode' => 'allowList'])->setStackFrameVariables(['terms' => [42]]); - $options->setFrameContextLines(2)->setFrameContextLines(-1); + public function testStackFrameVariablesAcceptKeyValueBehavior(): void + { + $options = new DataCollectionOptions([ + 'stack_frame_variables' => ['mode' => 'allowList', 'terms' => ['request_id']], + ]); - $this->assertSame('allowList', $options->getCookies()['mode']); - $this->assertSame(['incomingRequest'], $options->getHttpBodies()); - $this->assertSame(['mode' => 'allowList', 'terms' => []], $options->getStackFrameVariables()); - $this->assertSame(2, $options->getFrameContextLines()); + $this->assertSame(['mode' => 'allowList', 'terms' => ['request_id']], $options->getStackFrameVariables()); + $this->assertTrue($options->shouldCollectStackFrameVariables()); } - public function testArrayAccessReadsNestedOptions(): void + public function testStackFrameVariableSetterPreservesMode(): void { $options = new DataCollectionOptions([ - 'http_headers' => [ - 'request' => ['mode' => 'allowList'], - ], + 'stack_frame_variables' => ['mode' => 'allowList', 'terms' => ['request_id']], ]); - $this->assertTrue(isset($options['http_headers'])); - $this->assertFalse(isset($options['unknown'])); - $this->assertSame('allowList', $options['http_headers']['request']['mode']); - $this->assertNull($options['unknown']); - $this->assertNull($options[0]); + $options->setStackFrameVariables(['terms' => ['trace_id']]); + + $this->assertSame(['mode' => 'allowList', 'terms' => ['trace_id']], $options->getStackFrameVariables()); } - public function testArrayAccessWritesUseResolver(): void + /** + * @dataProvider stackFrameVariableBooleanProvider + */ + public function testStackFrameVariablesAcceptBooleanShorthand(bool $value, array $expected): void { $options = new DataCollectionOptions(); - $options['http_headers'] = [ - 'request' => ['mode' => 'off'], - ]; - $this->assertSame('off', $options['http_headers']['request']['mode']); - $this->assertSame('denyList', $options['http_headers']['response']['mode']); + $options->setStackFrameVariables($value); - $options['http_headers'] = ['request' => ['mode' => 'invalid']]; - $options['frame_context_lines'] = -1; - $options['http_bodies'] = ['incomingRequest']; - $options['http_bodies'] = null; - $options['unknown'] = true; - $options[] = true; + $this->assertSame($expected, $options->getStackFrameVariables()); + $this->assertSame($value, $options->shouldCollectStackFrameVariables()); + } - $this->assertSame('off', $options['http_headers']['request']['mode']); - $this->assertSame(5, $options['frame_context_lines']); - $this->assertSame(['incomingRequest'], $options['http_bodies']); - $this->assertNull($options['unknown']); + public function stackFrameVariableBooleanProvider(): \Generator + { + yield 'enabled' => [true, ['mode' => 'denyList', 'terms' => []]]; + yield 'disabled' => [false, ['mode' => 'off', 'terms' => []]]; } - public function testArrayAccessUnsetRestoresDefault(): void + /** + * @dataProvider invalidConstructorValueProvider + * + * @param array $configuration + * @param mixed $expected + */ + public function testInvalidConstructorValuesUseDefaults(array $configuration, string $getter, $expected): void { - $options = new DataCollectionOptions([ - 'user_info' => false, - 'http_bodies' => [], - 'stack_frame_variables' => false, - ]); + $options = new DataCollectionOptions($configuration); + + $this->assertSame($expected, $options->{$getter}()); + } - unset( - $options['user_info'], - $options['http_bodies'], - $options['stack_frame_variables'], - $options['unknown'], - $options[0] - ); - - $this->assertTrue($options['user_info']); - $this->assertSame(DataCollectionOptions::HTTP_BODY_TYPES, $options['http_bodies']); - $this->assertSame(['mode' => 'denyList', 'terms' => []], $options['stack_frame_variables']); + public function invalidConstructorValueProvider(): \Generator + { + yield 'cookies' => [['cookies' => ['mode' => 'invalid', 'terms' => [42]]], 'getCookies', ['mode' => 'denyList', 'terms' => []]]; + yield 'HTTP bodies' => [['http_bodies' => ['invalid']], 'getHttpBodies', DataCollectionOptions::HTTP_BODY_TYPES]; + yield 'GenAI' => [['gen_ai' => ['inputs' => 'invalid']], 'getGenAi', ['inputs' => true, 'outputs' => true]]; + yield 'database query data' => [['database_query_data' => 'invalid'], 'shouldCollectDatabaseQueryData', true]; + yield 'queues' => [['queues' => 'invalid'], 'shouldCollectQueues', true]; + yield 'stack frame variables' => [['stack_frame_variables' => ['mode' => 'invalid']], 'getStackFrameVariables', ['mode' => 'denyList', 'terms' => []]]; + yield 'frame context lines' => [['frame_context_lines' => -1], 'getFrameContextLines', 5]; + } + + /** + * @dataProvider invalidSetterValueProvider + * + * @param mixed $valid + * @param mixed $invalid + * @param mixed $expected + */ + public function testInvalidSetterValuesKeepCurrentValues(string $setter, string $getter, $valid, $invalid, $expected): void + { + $options = new DataCollectionOptions(); + $options->{$setter}($valid); + + $this->assertSame($options, $options->{$setter}($invalid)); + $this->assertSame($expected, $options->{$getter}()); + } + + public function invalidSetterValueProvider(): \Generator + { + yield 'cookies' => ['setCookies', 'getCookies', ['mode' => 'allowList'], ['mode' => 'invalid'], ['mode' => 'allowList', 'terms' => []]]; + yield 'HTTP bodies' => ['setHttpBodies', 'getHttpBodies', ['incomingRequest'], ['invalid'], ['incomingRequest']]; + yield 'stack frame variables' => ['setStackFrameVariables', 'getStackFrameVariables', ['mode' => 'allowList'], ['terms' => [42]], ['mode' => 'allowList', 'terms' => []]]; + yield 'frame context lines' => ['setFrameContextLines', 'getFrameContextLines', 2, -1, 2]; } } diff --git a/tests/DataCollection/DataCollectionPolicyTest.php b/tests/DataCollection/DataCollectionPolicyTest.php new file mode 100644 index 000000000..66bfa0eb3 --- /dev/null +++ b/tests/DataCollection/DataCollectionPolicyTest.php @@ -0,0 +1,104 @@ +createMock(HubInterface::class); + $hub->method('getClient')->willReturn(null); + $policy = DataCollectionPolicy::fromHub($hub); + + $this->assertTrue($policy->isLegacyMode()); + $this->assertFalse($policy->shouldCollectUserInfo()); + $this->assertNull($policy->getOptions()); + } + + /** + * @dataProvider userInfoProvider + */ + public function testUserInfoUsesOnlyTheActiveMode(array $configuration, bool $expected): void + { + $policy = DataCollectionPolicy::fromOptions(new Options($configuration)); + + $this->assertSame($expected, $policy->shouldCollectUserInfo()); + } + + public function userInfoProvider(): \Generator + { + yield 'legacy default' => [[], false]; + yield 'legacy disabled' => [['send_default_pii' => false], false]; + yield 'legacy enabled' => [['send_default_pii' => true], true]; + yield 'configured default ignores disabled legacy option' => [['data_collection' => [], 'send_default_pii' => false], true]; + yield 'configured default ignores enabled legacy option' => [['data_collection' => [], 'send_default_pii' => true], true]; + yield 'configured disabled' => [['data_collection' => ['user_info' => false], 'send_default_pii' => true], false]; + } + + public function testPolicyObservesLegacyPiiUpdates(): void + { + $options = new Options(['send_default_pii' => false]); + $policy = DataCollectionPolicy::fromOptions($options); + + $options->updateOptions(['send_default_pii' => true]); + + $this->assertTrue($policy->isLegacyMode()); + $this->assertTrue($policy->shouldCollectUserInfo()); + } + + public function testPolicyObservesDataCollectionReplacement(): void + { + $options = new Options(['send_default_pii' => true]); + $policy = DataCollectionPolicy::fromOptions($options); + + $options->updateOptions(['data_collection' => ['user_info' => false]]); + + $this->assertFalse($policy->isLegacyMode()); + $this->assertFalse($policy->shouldCollectUserInfo()); + } + + public function testPolicyObservesMutableDataCollectionOptions(): void + { + $policy = DataCollectionPolicy::fromOptions(new Options(['data_collection' => ['user_info' => false]])); + $dataCollection = $policy->getDataCollection(); + $this->assertInstanceOf(DataCollectionOptions::class, $dataCollection); + + $dataCollection->setUserInfo(true); + + $this->assertTrue($policy->shouldCollectUserInfo()); + } + + public function testPolicyObservesTransitionBackToLegacyMode(): void + { + $options = new Options(['data_collection' => [], 'send_default_pii' => true]); + $policy = DataCollectionPolicy::fromOptions($options); + + $options->updateOptions(['data_collection' => null]); + + $this->assertTrue($policy->isLegacyMode()); + $this->assertTrue($policy->shouldCollectUserInfo()); + } + + public function testFromHubUsesTheCurrentClientOptions(): void + { + $options = new Options(['data_collection' => []]); + $client = $this->createMock(ClientInterface::class); + $client->method('getOptions')->willReturn($options); + $hub = $this->createMock(HubInterface::class); + $hub->method('getClient')->willReturn($client); + + $policy = DataCollectionPolicy::fromHub($hub); + + $this->assertSame($options, $policy->getOptions()); + $this->assertSame($options->getDataCollection(), $policy->getDataCollection()); + } +} diff --git a/tests/DataCollection/HttpBodyCollectorTest.php b/tests/DataCollection/HttpBodyCollectorTest.php new file mode 100644 index 000000000..4c71895f8 --- /dev/null +++ b/tests/DataCollection/HttpBodyCollectorTest.php @@ -0,0 +1,346 @@ +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 'boolean input' => [true, '', null]; + yield 'numeric input' => [123, '', null]; + yield 'encoded empty string' => ['""', 'application/json', '[Filtered]']; + 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]']; + } + + /** + * @dataProvider bodyDirectionProvider + */ + public function testConfiguredDefaultsCollectEveryDirection(string $direction): void + { + $this->assertSame([], HttpBodyCollector::collect($this->options(), $direction, [])); + } + + /** + * @dataProvider legacyBodyDirectionProvider + */ + public function testLegacyModeDoesNotCollectBodies(string $direction, bool $sendDefaultPii): void + { + $policy = DataCollectionPolicy::fromOptions(new Options(['send_default_pii' => $sendDefaultPii])); + + $this->assertNull(HttpBodyCollector::collect($policy, $direction, [])); + } + + /** + * @dataProvider selectedBodyDirectionProvider + */ + public function testConfiguredBodyDirectionsCanBeSelected(string $selected, string $direction, $expected): void + { + $options = $this->options(['data_collection' => ['http_bodies' => [$selected]]]); + + $this->assertSame($expected, HttpBodyCollector::collect($options, $direction, [])); + } + + /** + * @dataProvider bodyDirectionProvider + */ + public function testEmptyBodySelectionDisablesEveryDirection(string $direction): void + { + $options = $this->options(['data_collection' => ['http_bodies' => []]]); + + $this->assertNull(HttpBodyCollector::collect($options, $direction, [])); + } + + /** + * @dataProvider bodyDirectionProvider + */ + public function testMissingOptionsHaveNoBodyLimit(string $direction): void + { + $this->assertSame(0, HttpBodyCollector::getMaxBodyLength(DataCollectionPolicy::fromOptions(null), $direction)); + } + + public function bodyDirectionProvider(): \Generator + { + foreach (DataCollectionOptions::HTTP_BODY_TYPES as $direction) { + yield $direction => [$direction]; + } + } + + public function legacyBodyDirectionProvider(): \Generator + { + foreach (DataCollectionOptions::HTTP_BODY_TYPES as $direction) { + yield $direction . ' with PII disabled' => [$direction, false]; + yield $direction . ' with PII enabled' => [$direction, true]; + } + } + + public function selectedBodyDirectionProvider(): \Generator + { + foreach (DataCollectionOptions::HTTP_BODY_TYPES as $selected) { + yield $selected . ' selected' => [$selected, $selected, []]; + foreach (array_diff(DataCollectionOptions::HTTP_BODY_TYPES, [$selected]) as $direction) { + yield $selected . ' excludes ' . $direction => [$selected, $direction, null]; + } + } + } + + /** + * @dataProvider disabledRequestSizeProvider + */ + public function testRequestSizeDisableDoesNotDisableResponses(string $size): void + { + $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 disabledRequestSizeProvider(): \Generator + { + yield 'none' => ['none']; + yield 'never' => ['never']; + } + + /** + * @dataProvider rawBodySizeBoundaryProvider + * + * @param array|null $expected + */ + public function testRawBodySizeBoundaries(string $direction, string $size, int $length, ?array $expected): void + { + $options = $this->options(['max_request_body_size' => $size]); + $body = ['x' => str_repeat('a', $length - 8)]; + $json = JSON::encode($body); + + $this->assertSame($length, \strlen($json)); + $this->assertSame($expected, HttpBodyCollector::collect($options, $direction, $json, 'application/json')); + } + + public function rawBodySizeBoundaryProvider(): \Generator + { + foreach (['incomingRequest', 'outgoingRequest'] as $direction) { + foreach (['small' => 1000, 'medium' => 10000, 'always' => 100000] as $size => $limit) { + yield $direction . ' ' . $size . ' below limit' => [$direction, $size, $limit - 1, ['x' => str_repeat('a', $limit - 9)]]; + yield $direction . ' ' . $size . ' at limit' => [$direction, $size, $limit, ['x' => str_repeat('a', $limit - 8)]]; + yield $direction . ' ' . $size . ' above limit' => [$direction, $size, $limit + 1, null]; + } + } + foreach (['incomingResponse', 'outgoingResponse'] as $direction) { + yield $direction . ' below limit' => [$direction, 'small', 99999, ['x' => str_repeat('a', 99991)]]; + yield $direction . ' at limit' => [$direction, 'small', 100000, ['x' => str_repeat('a', 99992)]]; + yield $direction . ' above limit' => [$direction, 'small', 100001, null]; + } + } + + /** + * @dataProvider bodyLimitProvider + */ + public function testBodyLimits(string $direction, string $size, int $expectedLimit): void + { + $this->assertSame($expectedLimit, HttpBodyCollector::getMaxBodyLength($this->options(['max_request_body_size' => $size]), $direction)); + } + + public function bodyLimitProvider(): \Generator + { + yield 'small request' => ['incomingRequest', 'small', 1000]; + yield 'medium request' => ['outgoingRequest', 'medium', 10000]; + yield 'always request' => ['incomingRequest', 'always', 100000]; + yield 'small response' => ['incomingResponse', 'small', 100000]; + yield 'medium response' => ['outgoingResponse', 'medium', 100000]; + } + + public function testLimitsMeasureBytesBeforeFiltering(): void + { + $options = $this->options(['max_request_body_size' => 'small']); + $this->assertNull(HttpBodyCollector::collect($options, 'incomingRequest', JSON::encode(['password' => str_repeat('é', 500)]), 'application/json')); + $this->assertNull(HttpBodyCollector::collect($options, 'incomingRequest', str_repeat('é', 501), 'text/plain')); + } + + /** + * @dataProvider bodyDirectionProvider + */ + public function testParsedArraysAreNotSerializedForSizeChecks(string $direction): void + { + $body = ['name' => str_repeat('a', 100001), 'password' => 'secret']; + $expected = ['name' => $body['name'], 'password' => '[Filtered]']; + + $this->assertSame($expected, HttpBodyCollector::collect($this->options(['max_request_body_size' => 'small']), $direction, $body)); + } + + public function testFilteringDoesNotRecheckTheResultSize(): void + { + $body = []; + for ($i = 0; $i < 60; ++$i) { + $body['token' . $i] = ''; + } + $raw = JSON::encode($body); + $expected = array_fill_keys(array_keys($body), '[Filtered]'); + $this->assertLessThan(1000, \strlen($raw)); + $this->assertGreaterThan(1000, \strlen(JSON::encode($expected))); + $this->assertSame($expected, HttpBodyCollector::collect($this->options(['max_request_body_size' => 'small']), 'incomingRequest', $raw, 'application/json')); + } + + 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 testHeaderAndCookieTermsDoNotAffectBodies(): void + { + $options = $this->options(['data_collection' => ['http_headers' => ['terms' => ['name']], 'cookies' => ['terms' => ['name']]]]); + + $this->assertSame(['name' => 'Alice'], HttpBodyCollector::collect($options, 'incomingRequest', ['name' => 'Alice'])); + } + + /** + * @dataProvider explicitBodyDataProvider + * + * @param mixed $explicit + */ + public function testExplicitBodyDataWins($explicit): void + { + $span = (new Span())->setData(['http.request.body.data' => $explicit]); + $data = HttpDataCollector::collectBodyData($this->options(), 'incomingRequest', ['password' => 'secret']); + + $span->setData(array_diff_key($data, $span->getData())); + + $this->assertSame($explicit, $span->getData()['http.request.body.data']); + } + + public function explicitBodyDataProvider(): \Generator + { + yield 'null' => [null]; + yield 'empty' => [[]]; + yield 'populated' => [['password' => 'explicit']]; + } + + public function testEmptyBodyDataIsRetained(): void + { + $this->assertSame( + ['http.response.body.data' => []], + HttpDataCollector::collectBodyData($this->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()); + } + + public function testParsedServerBodyIsAuthoritative(): void + { + $request = (new ServerRequest('POST', '/', ['Content-Type' => 'application/json'], '{"name":"raw"}'))->withParsedBody([]); + + $this->assertSame([], HttpBodyCollector::collectServerRequest($this->options(), $request)); + } + + public function testNonSeekableServerStreamIsNotCollected(): void + { + $request = new ServerRequest('POST', '/', [], 'secret'); + + $this->assertNull(HttpBodyCollector::collectServerRequest($this->options(), $request->withBody(new NoSeekStream($request->getBody())))); + } + + public function testOversizedServerStreamReadIsBoundedAndRestored(): void + { + $request = new ServerRequest('POST', '/', ['Content-Type' => 'application/json'], str_repeat('a', 1001)); + $request->getBody()->seek(3); + + $this->assertNull(HttpBodyCollector::collectServerRequest($this->options(['max_request_body_size' => 'small']), $request)); + $this->assertSame(3, $request->getBody()->tell()); + } + + public function testDeclaredOversizedServerStreamPreservesPosition(): void + { + $request = new ServerRequest('POST', '/', ['Content-Length' => '1001'], str_repeat('a', 1001)); + $request->getBody()->seek(3); + + $this->assertNull(HttpBodyCollector::collectServerRequest($this->options(['max_request_body_size' => 'small']), $request)); + $this->assertSame(3, $request->getBody()->tell()); + } + + /** + * @param array $options + */ + private function options(array $options = []): DataCollectionPolicy + { + return DataCollectionPolicy::fromOptions(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..5b18470a5 --- /dev/null +++ b/tests/DataCollection/HttpDataCollectorTest.php @@ -0,0 +1,337 @@ +policy([]), [ + 'authorization' => ['secret'], 'cookie' => ['theme=dark; session_id=secret'], + ]); + $this->assertSame([ + 'http.request.header.authorization' => ['[Filtered]'], + 'http.request.header.cookie.theme' => 'dark', + 'http.request.header.cookie.session_id' => '[Filtered]', + ], $data); + $this->assertSame([], HttpDataCollector::collectRequestData($this->policy(null), [])); + } + + public function testCollectQueryStringPreservesLegacyBehavior(): void + { + $policy = $this->policy(null); + + $this->assertSame('token=secret&q=a%20b', HttpDataCollector::collectQueryString($policy, 'token=secret&q=a%20b')); + $this->assertNull(HttpDataCollector::collectQueryString($policy, '')); + } + + public function testConfiguredDefaultsCollectParsedCookiesAndHeaders(): void + { + [$request, $response] = $this->collectParsedCookies($this->policy([])); + + $this->assertSame([ + 'http.request.header.x-test' => ['visible'], + 'http.request.header.cookie.theme' => 'parsed', + 'http.request.header.cookie.session_id' => '[Filtered]', + ], $request); + $this->assertSame([ + 'http.response.header.x-test' => ['visible'], + 'http.response.header.set_cookie.theme' => ['first', 'second'], + 'http.response.header.set_cookie.locale' => null, + 'http.response.header.set_cookie.session_id' => '[Filtered]', + ], $response); + } + + public function testParsedCookiesAreCollectedWhenHeadersAreDisabled(): void + { + [$request, $response] = $this->collectParsedCookies($this->policy(['http_headers' => ['mode' => 'off']])); + + $this->assertSame([ + 'http.request.header.cookie.theme' => 'parsed', + 'http.request.header.cookie.session_id' => '[Filtered]', + ], $request); + $this->assertSame([ + 'http.response.header.set_cookie.theme' => ['first', 'second'], + 'http.response.header.set_cookie.locale' => null, + 'http.response.header.set_cookie.session_id' => '[Filtered]', + ], $response); + } + + public function testHeadersAreCollectedWhenCookiesAreDisabled(): void + { + [$request, $response] = $this->collectParsedCookies($this->policy(['cookies' => ['mode' => 'off']])); + + $this->assertSame(['http.request.header.x-test' => ['visible']], $request); + $this->assertSame(['http.response.header.x-test' => ['visible']], $response); + } + + public function testLegacyModeDoesNotCollectParsedCookiesOrSpanHeaders(): void + { + [$request, $response] = $this->collectParsedCookies($this->policy(null)); + + $this->assertSame([], $request); + $this->assertSame([], $response); + } + + public function testEmptyParsedCookiesDoNotFallBackToRawHeaders(): void + { + $policy = $this->policy([]); + $this->assertSame([], HttpDataCollector::collectRequestData($policy, ['cookie' => ['theme=raw']], [])); + $this->assertSame([], HttpDataCollector::collectResponseData($policy, ['set-cookie' => ['theme=raw']], [])); + $this->assertSame(['http.request.header.cookie.theme' => 'raw'], HttpDataCollector::collectRequestData($policy, ['cookie' => ['theme=raw']])); + $this->assertSame(['http.response.header.set_cookie.theme' => 'raw'], HttpDataCollector::collectResponseData($policy, ['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($this->policy(null), $declared, $legacy)); + $this->assertSame('https://example.com/?tag=a&tag=b&%74oken=[Filtered]&q=a+b', HttpDataCollector::collectUrl($this->policy([]), $declared, $legacy)); + $this->assertSame('https://example.com/', HttpDataCollector::collectUrl($this->policy(['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($this->policy([]), $headers)); + $this->assertSame([], HttpDataCollector::collectResponseData($this->policy(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($this->policy([]), '')); + $this->assertSame([], HttpDataCollector::collectQueryData($this->policy(['url_query_params' => ['mode' => 'off']]), 'token=secret')); + $this->assertSame(['http.query' => 'token=secret'], HttpDataCollector::collectQueryData($this->policy(null), 'token=secret')); + $this->assertSame( + ['http.query' => '%74oken=[Filtered]&page=new&q=a+b'], + HttpDataCollector::collectQueryData( + $this->policy([]), + '%74oken=secret&page=new&q=a+b' + ) + ); + } + + public function testCollectQueryStringPreservesEncoding(): void + { + $this->assertSame( + 'api%5Ftoken=[Filtered]&q=a%20b%26c', + HttpDataCollector::collectQueryString($this->policy([]), 'api%5Ftoken=secret&q=a%20b%26c') + ); + } + + public function testEmptyAndDisabledQueryStringsAreNotCollected(): void + { + $this->assertNull(HttpDataCollector::collectQueryString($this->policy([]), '')); + $this->assertNull(HttpDataCollector::collectQueryString($this->policy(['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($this->policy($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']; + yield 'query collection disabled' => [['url_query_params' => ['mode' => 'off']], $url, 'https://example.com/a']; + 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)); + } + + /** + * @dataProvider headerDirectionAndCookieModeProvider + */ + public function testHeadersCannotOptIntoCookieHeaders(string $method, string $cookieMode): void + { + $options = new DataCollectionOptions([ + 'http_headers' => ['mode' => 'allowList', 'terms' => ['cookie']], + 'cookies' => ['mode' => $cookieMode, 'terms' => ['theme']], + ]); + + $this->assertSame([], HttpDataCollector::$method($options, [ + 'cookie' => ['theme=dark'], + 'set-cookie' => ['theme=light'], + ])); + } + + public function headerDirectionAndCookieModeProvider(): \Generator + { + yield 'request with cookies off' => ['collectRequestHeaders', 'off']; + yield 'request with cookie deny list' => ['collectRequestHeaders', 'denyList']; + yield 'request with cookie allow list' => ['collectRequestHeaders', 'allowList']; + yield 'response with cookies off' => ['collectResponseHeaders', 'off']; + yield 'response with cookie deny list' => ['collectResponseHeaders', 'denyList']; + yield 'response with cookie allow list' => ['collectResponseHeaders', 'allowList']; + } + + /** + * @dataProvider parsedCookieBehaviorProvider + * + * @param array $behavior + * @param array $expected + */ + public function testParsedCookiesAreIndependentOfHeaders(string $method, array $behavior, array $expected): void + { + $options = new DataCollectionOptions(['cookies' => $behavior, 'http_headers' => ['mode' => 'off']]); + + $this->assertSame($expected, HttpDataCollector::$method($options, ['theme' => 'dark', 'SESSION_id' => 'secret'])); + } + + public function parsedCookieBehaviorProvider(): \Generator + { + foreach (['collectRequestCookies' => 'http.request.header.cookie.', 'collectResponseCookies' => 'http.response.header.set_cookie.'] as $method => $prefix) { + yield $method . ' deny list' => [$method, ['mode' => 'denyList'], [ + $prefix . 'theme' => 'dark', + $prefix . 'SESSION_id' => '[Filtered]', + ]]; + yield $method . ' allow list' => [$method, ['mode' => 'allowList', 'terms' => ['theme', 'session']], [ + $prefix . 'theme' => 'dark', + $prefix . 'SESSION_id' => '[Filtered]', + ]]; + yield $method . ' off' => [$method, ['mode' => 'off'], []]; + yield $method . ' custom deny list' => [$method, ['mode' => 'denyList', 'terms' => ['theme']], [ + $prefix . 'theme' => '[Filtered]', + $prefix . 'SESSION_id' => '[Filtered]', + ]]; + } + } + + public function testMalformedRequestCookieHeaderUsesFilteredFallback(): void + { + $this->assertSame( + ['http.request.header.cookie' => '[Filtered]'], + HttpDataCollector::collectRequestData($this->policy([]), ['cookie' => ['malformed']]) + ); + } + + public function testMalformedResponseCookieHeaderUsesFilteredFallback(): void + { + $this->assertSame( + ['http.response.header.set_cookie' => '[Filtered]'], + HttpDataCollector::collectResponseData($this->policy([]), ['set-cookie' => ['malformed']]) + ); + } + + public function testMalformedRequestCookieFallbackIsRetainedWithParsedCookies(): void + { + $this->assertSame( + ['http.request.header.cookie.theme' => 'parsed', 'http.request.header.cookie' => '[Filtered]'], + HttpDataCollector::collectRequestData($this->policy([]), ['cookie' => ['malformed']], ['theme' => 'parsed']) + ); + } + + public function testMalformedResponseCookieFallbackIsRetainedWithParsedCookies(): void + { + $this->assertSame( + ['http.response.header.set_cookie.theme' => 'parsed', 'http.response.header.set_cookie' => '[Filtered]'], + HttpDataCollector::collectResponseData($this->policy([]), ['set-cookie' => ['malformed']], [['theme', 'parsed']]) + ); + } + + public function testRequestCookieHeaderParsing(): void + { + $this->assertSame(['theme' => ['dark', 'light'], 'session' => 'a=b', 'empty' => ''], HttpDataCollector::parseRequestCookies([ + 'theme=dark; session=a=b; empty=; malformed; =ignored', 'theme=light', + ])); + } + + public function testResponseCookieHeaderParsing(): void + { + $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', + ])); + } + + /** + * @return array{array, array} + */ + private function collectParsedCookies(DataCollectionPolicy $policy): array + { + $headers = ['x-test' => ['visible'], 'cookie' => ['theme=raw'], 'set-cookie' => ['theme=raw']]; + + return [ + HttpDataCollector::collectRequestData($policy, $headers, ['theme' => 'parsed', 'session_id' => 'secret']), + HttpDataCollector::collectResponseData($policy, $headers, [ + ['theme', 'first'], ['theme', 'second'], ['locale', null], ['session_id', 'secret'], + ]), + ]; + } + + /** + * @param array|null $dataCollection + */ + private function policy(?array $dataCollection): DataCollectionPolicy + { + return DataCollectionPolicy::fromOptions(new Options(['data_collection' => $dataCollection])); + } +} 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..5daff301f 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,81 @@ public function testFilterHeadersAppliesAllowList(): void ], $filtered); } + public function testCustomCookieAllowTermsMatchWholeNames(): void + { + $behavior = ['mode' => 'allowList', 'terms' => ['THEME', 'api_token']]; + + $this->assertSame([ + 'theme' => 'dark', + 'user_theme' => '[Filtered]', + 'api_token' => '[Filtered]', + ], KeyValueDataFilter::filterCookies($this->customTermInput(), $behavior)); + } + + public function testCustomCookieDenyTermsMatchSubstrings(): void + { + $behavior = ['mode' => 'denyList', 'terms' => ['THEME', 'api_token']]; + + $this->assertSame([ + 'theme' => '[Filtered]', + 'user_theme' => '[Filtered]', + 'api_token' => '[Filtered]', + ], KeyValueDataFilter::filterCookies($this->customTermInput(), $behavior)); + } + + public function testCustomTermsAreUsedForKeyValueData(): void + { + $behavior = ['mode' => 'denyList', 'terms' => ['THEME']]; + + $this->assertSame([ + 'theme' => '[Filtered]', + 'user_theme' => '[Filtered]', + 'api_token' => '[Filtered]', + ], KeyValueDataFilter::filterKeyValueData($this->customTermInput(), $behavior)); + } + + public function testCustomTermsAreUsedForHeaders(): void + { + $behavior = ['mode' => 'denyList', 'terms' => ['THEME']]; + $headers = array_map(static function (string $value): array { return [$value]; }, $this->customTermInput()); + + $this->assertSame([ + 'theme' => ['[Filtered]'], + 'user_theme' => ['[Filtered]'], + 'api_token' => ['[Filtered]'], + ], KeyValueDataFilter::filterHeaders($headers, $behavior)); + } + + /** + * @dataProvider customQueryTermProvider + * + * @param array $behavior + */ + public function testCustomTermsAreUsedForQueryStrings(array $behavior, string $expected): void + { + $query = '%74heme=dark&user_theme=light&api_token=secret&q=a%20b'; + + $this->assertSame($expected, KeyValueDataFilter::filterQueryString($query, $behavior)); + } + + public function customQueryTermProvider(): \Generator + { + yield 'allow list uses whole names' => [ + ['mode' => 'allowList', 'terms' => ['THEME', 'api_token']], + '%74heme=dark&user_theme=[Filtered]&api_token=[Filtered]&q=[Filtered]', + ]; + yield 'deny list uses partial names' => [ + ['mode' => 'denyList', 'terms' => ['THEME', 'api_token']], + '%74heme=[Filtered]&user_theme=[Filtered]&api_token=[Filtered]&q=a%20b', + ]; + } + + 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']]; @@ -234,4 +290,12 @@ public function testFilterQueryStringDoesNotTreatCookieNamesAsCookieHeaders(): v $this->assertSame('cookie=foo&set-cookie=bar', $filtered); } + + /** + * @return array + */ + private function customTermInput(): array + { + return ['theme' => 'dark', 'user_theme' => 'light', 'api_token' => 'secret']; + } } diff --git a/tests/DataCollection/RequestDataCollectorTest.php b/tests/DataCollection/RequestDataCollectorTest.php index d17ec4bc2..c97e19892 100644 --- a/tests/DataCollection/RequestDataCollectorTest.php +++ b/tests/DataCollection/RequestDataCollectorTest.php @@ -5,16 +5,68 @@ namespace Sentry\Tests\DataCollection; use PHPUnit\Framework\TestCase; -use Sentry\DataCollection\DataCollectionOptions; +use Sentry\DataCollection\DataCollectionPolicy; use Sentry\DataCollection\RequestDataCollector; +use Sentry\Options; final class RequestDataCollectorTest extends TestCase { - public function testUsesDataCollectionDistinguishesConfiguredAndLegacyModes(): void + /** + * @dataProvider userCollectionProvider + * + * @param array $expectedUser + * @param array $expectedIp + */ + public function testCollectUserInfoAndClientIpFollowConfiguration(?Options $options, array $expectedUser, array $expectedIp): void + { + $collector = new RequestDataCollector(DataCollectionPolicy::fromOptions($options)); + $user = ['id' => 'alice', 'ip_address' => '203.0.113.7', 'impersonator_username' => 'admin']; + + $this->assertSame($expectedUser, $collector->collectUserInfo($user)); + $this->assertSame($expectedIp, $collector->collectClientIpData('203.0.113.7')); + $this->assertSame([], $collector->collectClientIpData(null)); + } + + /** + * @return \Generator, array}> + */ + public function userCollectionProvider(): \Generator + { + $user = ['id' => 'alice', 'ip_address' => '203.0.113.7', 'impersonator_username' => 'admin']; + $ip = ['net.peer.ip' => '203.0.113.7']; + + yield 'no options' => [null, [], []]; + yield 'legacy PII disabled' => [new Options(['send_default_pii' => false]), [], []]; + yield 'legacy PII enabled' => [new Options(['send_default_pii' => true]), $user, $ip]; + yield 'null collection with PII disabled' => [new Options(['send_default_pii' => false, 'data_collection' => null]), [], []]; + yield 'null collection with PII enabled' => [new Options(['send_default_pii' => true, 'data_collection' => null]), $user, $ip]; + yield 'configured defaults override PII disabled' => [new Options(['send_default_pii' => false, 'data_collection' => []]), $user, $ip]; + yield 'configured defaults ignore PII enabled' => [new Options(['send_default_pii' => true, 'data_collection' => []]), $user, $ip]; + yield 'unrelated configuration keeps user info enabled' => [new Options(['send_default_pii' => false, 'data_collection' => ['http_headers' => ['mode' => 'off']]]), $user, $ip]; + yield 'user info enabled' => [new Options(['data_collection' => ['user_info' => true]]), $user, $ip]; + yield 'user info disabled' => [new Options(['data_collection' => ['user_info' => false]]), [], []]; + } + + /** + * @dataProvider collectorConfigurationProvider + * + * @param array $configuration + */ + public function testCollectorPreservesHeaderRestrictions(array $configuration): void + { + $policy = DataCollectionPolicy::fromOptions(new Options($configuration)); + $collector = new RequestDataCollector($policy, ['x-tenant-id']); + + $this->assertSame([ + 'X-Tenant-ID' => ['[Filtered]'], + 'X-Test' => ['visible'], + ], $collector->collectHeaders(['X-Tenant-ID' => ['private'], 'X-Test' => ['visible']])); + } + + public function collectorConfigurationProvider(): \Generator { - $this->assertFalse($this->legacyCollector(false)->usesDataCollection()); - $this->assertFalse($this->legacyCollector(true)->usesDataCollection()); - $this->assertTrue($this->collector([])->usesDataCollection()); + yield 'legacy' => [[]]; + yield 'configured' => [['data_collection' => []]]; } public function testShouldCollectUserInfoFollowsLegacySendDefaultPii(): void @@ -25,8 +77,8 @@ public function testShouldCollectUserInfoFollowsLegacySendDefaultPii(): void public function testShouldCollectUserInfoUsesDataCollectionWhenConfigured(): void { - $enabled = new RequestDataCollector(new DataCollectionOptions(['user_info' => true]), false); - $disabled = new RequestDataCollector(new DataCollectionOptions(['user_info' => false]), true); + $enabled = $this->collector(['user_info' => true]); + $disabled = $this->collector(['user_info' => false]); $this->assertTrue($enabled->shouldCollectUserInfo()); $this->assertFalse($disabled->shouldCollectUserInfo()); @@ -99,6 +151,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 +218,78 @@ public function testCollectHeadersUsesRequestHeaderBehavior(): void ])); } - public function testCollectHeadersReturnsNullWhenRequestHeadersAreDisabled(): void + public function testMalformedCookieFallbackUsesCookiePolicy(): void { - $collector = $this->collector([ - 'http_headers' => [ - 'request' => ['mode' => 'off'], - 'response' => ['mode' => 'denyList'], - ], - ]); - - $this->assertNull($collector->collectHeaders(['X-Request-Id' => ['request-id']])); + $fallback = ['Cookie' => ['[Filtered]']]; + + $this->assertSame($fallback, $this->collector([])->collectMalformedCookieHeader(['malformed'])); + $this->assertSame($fallback, $this->collector(['http_headers' => ['mode' => 'off']])->collectMalformedCookieHeader(['malformed'])); + $this->assertSame([], $this->collector(['cookies' => ['mode' => 'off']])->collectMalformedCookieHeader(['malformed'])); + $this->assertSame([], $this->collector([])->collectMalformedCookieHeader(['theme=dark'])); + $this->assertSame([], $this->legacyCollector(false)->collectMalformedCookieHeader(['malformed'])); + $this->assertSame([], $this->legacyCollector(true)->collectMalformedCookieHeader(['malformed'])); } - public function testShouldCollectRequestBodyPreservesLegacyBehavior(): void + /** + * @dataProvider cookieModeProvider + */ + public function testCookieHeadersAreExcludedFromHeaderCollection(string $cookieMode): void { - $this->assertTrue($this->legacyCollector(false)->shouldCollectRequestBody()); - $this->assertTrue($this->legacyCollector(true)->shouldCollectRequestBody()); - } + $collector = $this->collector([ + 'cookies' => ['mode' => $cookieMode, 'terms' => ['theme']], + 'http_headers' => ['mode' => 'allowList', 'terms' => ['cookie', 'x-test']], + ]); - public function testShouldCollectRequestBodyUsesIncomingRequestBodyType(): void - { - $this->assertTrue($this->collector(['http_bodies' => ['incomingRequest']])->shouldCollectRequestBody()); - $this->assertFalse($this->collector(['http_bodies' => []])->shouldCollectRequestBody()); - $this->assertFalse($this->collector(['http_bodies' => ['outgoingRequest']])->shouldCollectRequestBody()); + $this->assertSame(['X-Test' => ['visible']], $collector->collectHeaders([ + 'CoOkIe' => ['theme=dark'], + 'SET-COOKIE' => ['malformed'], + 'X-Test' => ['visible'], + ])); } - public function testCollectRequestBodyPreservesLegacyBehavior(): void + public function cookieModeProvider(): \Generator { - $body = ['password' => 'secret']; - - $this->assertSame($body, $this->legacyCollector(false)->collectRequestBody($body)); - $this->assertSame('raw body', $this->legacyCollector(true)->collectRequestBody('raw body')); + yield 'off' => ['off']; + yield 'deny list' => ['denyList']; + yield 'allow list' => ['allowList']; } - public function testCollectRequestBodyFiltersStructuredSensitiveDataRecursively(): void + public function testCookiesAreCollectedWhenHeadersAreDisabled(): void { - $collector = $this->collector(['http_bodies' => ['incomingRequest']]); + $collector = $this->collector(['http_headers' => ['mode' => 'off']]); - $this->assertSame([ - 'password' => '[Filtered]', - 'user' => [ - 'api_token' => '[Filtered]', - 'name' => 'alice', - ], - ], $collector->collectRequestBody([ - 'password' => 'secret', - 'user' => [ - 'api_token' => 'token', - 'name' => 'alice', - ], + $this->assertNull($collector->collectHeaders(['Cookie' => ['theme=dark']])); + $this->assertSame(['theme' => 'dark', 'session_id' => '[Filtered]'], $collector->collectCookies([ + 'theme' => 'dark', + 'session_id' => 'secret', ])); } - public function testCollectRequestBodyFiltersRawData(): void + public function testExplicitHeaderRestrictionsArePreservedWithDataCollection(): void { - $collector = $this->collector(['http_bodies' => ['incomingRequest']]); - - $this->assertSame('[Filtered]', $collector->collectRequestBody('raw body')); + $policy = DataCollectionPolicy::fromOptions(new Options(['data_collection' => []])); + $collector = new RequestDataCollector($policy, ['x-tenant-id']); + $this->assertSame([ + '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 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']])); } /** @@ -229,7 +299,9 @@ private function legacyCollector( bool $sendDefaultPii, array $piiSanitizeHeaders = RequestDataCollector::DEFAULT_PII_SANITIZE_HEADERS ): RequestDataCollector { - return new RequestDataCollector(null, $sendDefaultPii, $piiSanitizeHeaders); + $policy = DataCollectionPolicy::fromOptions(new Options(['send_default_pii' => $sendDefaultPii])); + + return new RequestDataCollector($policy, $piiSanitizeHeaders); } /** @@ -237,6 +309,8 @@ private function legacyCollector( */ private function collector(array $dataCollection): RequestDataCollector { - return new RequestDataCollector(new DataCollectionOptions($dataCollection), false); + $policy = DataCollectionPolicy::fromOptions(new Options(['data_collection' => $dataCollection])); + + return new RequestDataCollector($policy); } } diff --git a/tests/Integration/RequestIntegrationTest.php b/tests/Integration/RequestIntegrationTest.php index e6e723a59..75cf87cbd 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,100 @@ 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']], + ]; + + yield 'malformed cookie uses filtered header fallback' => [ + ['data_collection' => ['http_headers' => ['mode' => 'off']]], + (new ServerRequest('GET', 'https://example.com/')) + ->withHeader('Cookie', 'malformed') + ->withCookieParams(['theme' => 'parsed']), + [ + 'url' => 'https://example.com/', + 'method' => 'GET', + 'cookies' => ['theme' => 'parsed'], + 'headers' => ['Cookie' => ['[Filtered]']], + ], + null, + null, + ]; + + 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 +626,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 +648,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 +666,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 +679,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/StacktraceBuilderTest.php b/tests/StacktraceBuilderTest.php index a17e0c161..d99f14bea 100644 --- a/tests/StacktraceBuilderTest.php +++ b/tests/StacktraceBuilderTest.php @@ -156,56 +156,58 @@ public static function realExceptionStackFrameVariablesDataProvider(): \Generato ], ]; - yield 'allow list filters values not matching configured terms' => [ - [ - 'data_collection' => [ - 'stack_frame_variables' => [ - 'mode' => 'allowList', - 'terms' => ['request'], + foreach (['request', 'requestId', 'REQUESTID'] as $term) { + yield 'allow list term: ' . $term => [ + [ + 'data_collection' => [ + 'stack_frame_variables' => [ + 'mode' => 'allowList', + 'terms' => [$term], + ], ], ], - ], - [ - 'stackFrameInner' => [ - 'apiToken' => '[Filtered]', - 'safeValue' => '[Filtered]', - ], - 'stackFrameMiddle' => [ - 'metadata' => '[Filtered]', - ], - 'stackFrameOuter' => [ - 'requestId' => 'request-123', - 'password' => '[Filtered]', - ], - ], - ]; - - yield 'deny list combines mandatory and custom terms' => [ - [ - 'data_collection' => [ - 'stack_frame_variables' => [ - 'mode' => 'denyList', - 'terms' => ['request'], + [ + 'stackFrameInner' => [ + 'apiToken' => '[Filtered]', + 'safeValue' => '[Filtered]', + ], + 'stackFrameMiddle' => [ + 'metadata' => '[Filtered]', + ], + 'stackFrameOuter' => [ + 'requestId' => $term === 'request' ? '[Filtered]' : 'request-123', + 'password' => '[Filtered]', ], ], - ], - [ - 'stackFrameInner' => [ - 'apiToken' => '[Filtered]', - 'safeValue' => 'safe', - ], - 'stackFrameMiddle' => [ - 'metadata' => [ - 'api_token' => '[Filtered]', - 'name' => 'alice', + ]; + + yield 'deny list term: ' . $term => [ + [ + 'data_collection' => [ + 'stack_frame_variables' => [ + 'mode' => 'denyList', + 'terms' => [$term], + ], ], ], - 'stackFrameOuter' => [ - 'requestId' => '[Filtered]', - 'password' => '[Filtered]', + [ + 'stackFrameInner' => [ + 'apiToken' => '[Filtered]', + 'safeValue' => 'safe', + ], + 'stackFrameMiddle' => [ + 'metadata' => [ + 'api_token' => '[Filtered]', + 'name' => 'alice', + ], + ], + 'stackFrameOuter' => [ + 'requestId' => '[Filtered]', + 'password' => '[Filtered]', + ], ], - ], - ]; + ]; + } } private static function createNestedException(): \RuntimeException diff --git a/tests/Tracing/GuzzleTracingMiddlewareTest.php b/tests/Tracing/GuzzleTracingMiddlewareTest.php index f8c2106c2..99e99f441 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; @@ -412,341 +409,305 @@ public function testTrace(Request $request, $expectedPromiseResult, array $expec * * @param array $options */ - public function testTraceFiltersQueryString(array $options, ?string $expectedQueryString): void + public function testTraceFiltersQueryString(array $options, string $expectedQueryString): void { - $rawQueryString = 'search=hello%20world&password=s%2Becret&custom=value'; - $sdkOptions = new Options(array_merge([ - 'traces_sample_rate' => 1, - ], $options)); - $client = $this->createMock(ClientInterface::class); - $client->expects($this->atLeastOnce()) - ->method('getOptions') - ->willReturn($sdkOptions); + [$spanData, $breadcrumbData] = $this->traceQueryExchange($options); - $hub = new Hub($client); - SentrySdk::setCurrentHub($hub); + $this->assertSame($expectedQueryString, $spanData['http.query']); + $this->assertSame($expectedQueryString, $breadcrumbData['http.query']); + } - $transaction = $hub->startTransaction(new TransactionContext()); - $hub->setSpan($transaction); + public function testTraceOmitsDisabledQueryString(): void + { + [$spanData, $breadcrumbData] = $this->traceExchange( + ['data_collection' => ['url_query_params' => ['mode' => 'off']]], + new Request('GET', 'https://www.example.com?password=secret'), + new Response() + ); - $middleware = GuzzleTracingMiddleware::trace($hub); - $function = $middleware(function (Request $request) use ($rawQueryString): PromiseInterface { - $this->assertSame($rawQueryString, $request->getUri()->getQuery()); + $this->assertArrayNotHasKey('http.query', $spanData); + $this->assertArrayNotHasKey('http.query', $breadcrumbData); + } - return new FulfilledPromise(new Response()); - }); + public function testTraceCollectsConfiguredUrlAndQueryString(): void + { + [$spanData, $breadcrumbData] = $this->traceConfiguredExchange(); - /** @var PromiseInterface $promise */ - $promise = $function(new Request('GET', 'https://www.example.com?' . $rawQueryString), []); - $promise->wait(); - - $spanData = $this->getHttpSpan($transaction)->getData(); - $breadcrumbData = $this->getBreadcrumbData($hub); - - if ($expectedQueryString === null) { - $this->assertArrayNotHasKey('http.query', $spanData); - $this->assertArrayNotHasKey('http.query', $breadcrumbData); - } else { - $this->assertSame($expectedQueryString, $spanData['http.query']); - $this->assertSame($expectedQueryString, $breadcrumbData['http.query']); - } + $this->assertSame('https://www.example.com/path?search=hello%20world&password=[Filtered]', $spanData['url.full']); + $this->assertSame('search=hello%20world&password=[Filtered]', $spanData['http.query']); + $this->assertSame($spanData['url.full'], $breadcrumbData['url.full']); + $this->assertSame($spanData['url.full'], $breadcrumbData['url']); + $this->assertSame($spanData['http.query'], $breadcrumbData['http.query']); } - public function testTraceCollectsConfiguredOutgoingHttpData(): void + public function testTraceCollectsConfiguredHeaders(): void { - $sdkOptions = new Options([ - 'traces_sample_rate' => 1, - 'data_collection' => [], - ]); - $client = $this->createMock(ClientInterface::class); - $client->expects($this->atLeastOnce()) - ->method('getOptions') - ->willReturn($sdkOptions); + [$spanData, $breadcrumbData] = $this->traceConfiguredExchange(); + $expected = [ + 'http.request.header.content-type' => ['application/json'], + 'http.request.header.authorization' => ['[Filtered]'], + 'http.response.header.content-type' => ['application/x-www-form-urlencoded'], + 'http.response.header.x-response-id' => ['response-123'], + ]; - $hub = new Hub($client); - SentrySdk::setCurrentHub($hub); + $this->assertSame($expected, array_intersect_key($spanData, $expected)); + $this->assertSame([], array_intersect_key($breadcrumbData, $expected)); + } - $transaction = $hub->startTransaction(new TransactionContext()); - $hub->setSpan($transaction); + public function testTraceCollectsConfiguredCookies(): void + { + [$spanData, $breadcrumbData] = $this->traceConfiguredExchange(); + $expected = [ + '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', + ]; - $response = new Response(200, [ - 'Content-Type' => 'application/x-www-form-urlencoded', - 'X-Response-Id' => 'response-123', - 'Set-Cookie' => [ - 'session_id=response-secret; Path=/; HttpOnly', - 'theme=light; Path=/', - ], - ], 'token=response-secret&status=ok'); - $middleware = GuzzleTracingMiddleware::trace($hub); - $function = $middleware(function (Request $request) use ($response): PromiseInterface { - $this->assertSame(0, $request->getBody()->tell()); + $this->assertSame($expected, array_intersect_key($spanData, $expected)); + $this->assertSame([], array_intersect_key($breadcrumbData, $expected)); + $this->assertArrayNotHasKey('http.request.header.cookie', $spanData); + $this->assertArrayNotHasKey('http.response.header.set-cookie', $spanData); + $this->assertArrayNotHasKey('http.request.header.cookie', $breadcrumbData); + $this->assertArrayNotHasKey('http.response.header.set-cookie', $breadcrumbData); + } - return new FulfilledPromise($response); - }); - $request = new Request( - 'POST', - 'https://www.example.com/path?search=hello%20world&password=request-secret#fragment', - [ - 'Content-Type' => 'application/json', - 'Authorization' => 'Bearer request-secret', - 'Cookie' => 'session_id=request-secret; theme=dark', - ], - '[{"password":"request-secret","name":"Alice"},"unkeyed-request-secret"]' - ); + public function testTraceDoesNotCollectBodiesOrConsumeStreams(): void + { + $request = $this->configuredRequest(); + $response = $this->configuredResponse(); - /** @var PromiseInterface $promise */ - $promise = $function($request, []); - $promise->wait(); + [$spanData] = $this->traceExchange(['data_collection' => []], $request, $response); + $this->assertArrayNotHasKey('http.request.body.data', $spanData); + $this->assertArrayNotHasKey('http.response.body.data', $spanData); $this->assertSame(0, $request->getBody()->tell()); $this->assertSame(0, $response->getBody()->tell()); + $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')); + } - $expectedSharedData = [ - 'url.full' => 'https://www.example.com/path?search=hello%20world&password=%5BFiltered%5D#fragment', - 'http.query' => 'search=hello%20world&password=[Filtered]', - ]; - $expectedSpanData = [ - 'http.request.header.content-type' => ['application/json'], - 'http.request.header.authorization' => ['[Filtered]'], - 'http.request.header.cookie' => ['[Filtered]'], - 'http.request.body.data' => [ - [ - 'password' => '[Filtered]', - 'name' => 'Alice', - ], - '[Filtered]', - ], - 'http.response.header.content-type' => ['application/x-www-form-urlencoded'], - 'http.response.header.x-response-id' => ['response-123'], - 'http.response.header.set-cookie' => ['[Filtered]', '[Filtered]'], - 'http.response.body.data' => [ - 'token' => '[Filtered]', - 'status' => 'ok', - ], + public function testTraceDoesNotExposeSensitiveConfiguredData(): void + { + [$spanData] = $this->traceConfiguredExchange(); + $encodedData = json_encode($spanData); + + $this->assertStringNotContainsString('request-secret', $encodedData); + $this->assertStringNotContainsString('response-secret', $encodedData); + } + + /** + * @dataProvider parsedCookieCollectionProvider + * + * @param array $options + * @param array $expectedCookies + */ + public function testParsedCookieCollectionIsIndependentOfHeaders(array $options, array $expectedCookies): void + { + [$data] = $this->traceExchange( + $options, + new Request('GET', 'https://example.com', ['Cookie' => 'theme=dark; session_id=secret']), + new Response(200, ['Set-Cookie' => ['theme=light; Path=/', 'session_id=secret; HttpOnly']]) + ); + $cookieKeys = array_fill_keys([ + 'http.request.header.cookie.theme', + 'http.request.header.cookie.session_id', + 'http.response.header.set_cookie.theme', + 'http.response.header.set_cookie.session_id', + ], true); + + $this->assertSame($expectedCookies, array_intersect_key($data, $cookieKeys)); + $this->assertArrayNotHasKey('http.request.header.cookie', $data); + $this->assertArrayNotHasKey('http.response.header.set-cookie', $data); + } + + public function parsedCookieCollectionProvider(): \Generator + { + $cookies = [ + 'http.request.header.cookie.theme' => 'dark', + 'http.request.header.cookie.session_id' => '[Filtered]', + 'http.response.header.set_cookie.theme' => 'light', + 'http.response.header.set_cookie.session_id' => '[Filtered]', ]; - $spanData = $this->getHttpSpan($transaction)->getData(); - $breadcrumbData = $this->getBreadcrumbData($hub); - foreach ($expectedSharedData as $key => $value) { - $this->assertSame($value, $spanData[$key]); - $this->assertSame($value, $breadcrumbData[$key]); - } - foreach ($expectedSpanData as $key => $value) { - $this->assertSame($value, $spanData[$key]); - $this->assertArrayNotHasKey($key, $breadcrumbData); - } - $this->assertSame($expectedSharedData['url.full'], $breadcrumbData['url']); - $this->assertStringNotContainsString('request-secret', json_encode($spanData)); - $this->assertStringNotContainsString('response-secret', json_encode($spanData)); + yield 'legacy with PII disabled' => [['send_default_pii' => false], []]; + yield 'legacy with PII enabled' => [['send_default_pii' => true], []]; + yield 'configured with PII disabled' => [['send_default_pii' => false, 'data_collection' => ['http_headers' => ['mode' => 'off']]], $cookies]; + yield 'configured with PII enabled' => [['send_default_pii' => true, 'data_collection' => ['http_headers' => ['mode' => 'off']]], $cookies]; + yield 'cookies disabled with PII disabled' => [['send_default_pii' => false, 'data_collection' => ['cookies' => ['mode' => 'off'], 'http_headers' => ['mode' => 'off']]], []]; + yield 'cookies disabled with PII enabled' => [['send_default_pii' => true, 'data_collection' => ['cookies' => ['mode' => 'off'], 'http_headers' => ['mode' => 'off']]], []]; } - public function testTraceDoesNotConsumeNonSeekableBodies(): void + public function testTraceUsesCurrentOptionsForResponseCollection(): void { - $sdkOptions = new Options([ + $options = new Options([ 'traces_sample_rate' => 1, - 'data_collection' => [], + 'data_collection' => ['http_headers' => ['mode' => 'off']], ]); $client = $this->createMock(ClientInterface::class); - $client->expects($this->atLeastOnce()) - ->method('getOptions') - ->willReturn($sdkOptions); - + $client->method('getOptions')->willReturn($options); $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()); + $function = (GuzzleTracingMiddleware::trace($hub))(static function () use ($options): PromiseInterface { + $options->updateOptions(['data_collection' => ['http_headers' => ['request' => ['mode' => 'off']]]]); - return new FulfilledPromise($response); + return new FulfilledPromise(new Response(200, ['X-Response' => 'visible'])); }); + $function(new Request('GET', 'https://example.com', ['X-Request' => 'hidden']), [])->wait(); - /** @var PromiseInterface $promise */ - $promise = $function(new Request( - 'POST', - 'https://www.example.com', - ['Content-Type' => 'application/json'], - $requestBody - ), []); - $promiseResult = $promise->wait(); + $data = $this->getHttpSpan($transaction)->getData(); + $this->assertArrayNotHasKey('http.request.header.x-request', $data); + $this->assertSame(['visible'], $data['http.response.header.x-response']); + } - $this->assertSame($response, $promiseResult); - $this->assertSame('{"response":"body"}', $promiseResult->getBody()->getContents()); + public function testTracePreservesExplicitSpanData(): void + { + $data = $this->traceWithExplicitSpanData(); - $spanData = $this->getHttpSpan($transaction)->getData(); - $this->assertArrayNotHasKey('http.request.body.data', $spanData); - $this->assertArrayNotHasKey('http.response.body.data', $spanData); + $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 testTraceSkipsBodiesLargerThanTheirLimits(): void + public function testTraceRespectsDisabledOutgoingHttpDataCollection(): void { - $client = $this->createMock(ClientInterface::class); - $client->expects($this->atLeastOnce()) - ->method('getOptions') - ->willReturn(new Options([ - 'traces_sample_rate' => 1, - 'data_collection' => [], - ])); + $options = ['data_collection' => [ + 'cookies' => ['mode' => 'off'], + 'http_headers' => ['mode' => 'off'], + 'http_bodies' => [], + 'url_query_params' => ['mode' => 'off'], + ]]; + [$spanData, $breadcrumbData] = $this->traceExchange($options, $this->configuredRequest(), $this->configuredResponse()); + $collectionKeys = array_fill_keys([ + 'http.query', + 'http.request.header.content-type', + 'http.request.header.cookie', + 'http.request.body.data', + 'http.response.header.content-type', + 'http.response.header.set-cookie', + 'http.response.body.data', + ], true); + $this->assertSame([], array_intersect_key($spanData, $collectionKeys)); + $this->assertSame([], array_intersect_key($breadcrumbData, $collectionKeys)); + } + + /** + * @param array $options + * + * @return array{array, array} + */ + private function traceQueryExchange(array $options): array + { + $query = 'search=hello%20world&password=s%2Becret&custom=value'; + $request = new Request('GET', 'https://www.example.com?' . $query); + $client = $this->createMock(ClientInterface::class); + $client->method('getOptions')->willReturn(new Options($options + ['traces_sample_rate' => 1])); $hub = new Hub($client); SentrySdk::setCurrentHub($hub); - $transaction = $hub->startTransaction(new TransactionContext()); $hub->setSpan($transaction); + $function = (GuzzleTracingMiddleware::trace($hub))(function (Request $forwardedRequest) use ($query): PromiseInterface { + $this->assertSame($query, $forwardedRequest->getUri()->getQuery()); - $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); + return new FulfilledPromise(new Response()); }); - /** @var PromiseInterface $promise */ - $promise = $function(new Request( - 'POST', - 'https://www.example.com', - ['Content-Type' => 'application/json'], - $oversizedRequestBody - ), []); - $promise->wait(); + $function($request, [])->wait(); - $spanData = $this->getHttpSpan($transaction)->getData(); - $this->assertArrayNotHasKey('http.request.body.data', $spanData); - $this->assertArrayNotHasKey('http.response.body.data', $spanData); + return [$this->getHttpSpan($transaction)->getData(), $this->getBreadcrumbData($hub)]; } /** - * @dataProvider httpBodySafetyLimitDataProvider + * @return array */ - public function testTraceAppliesHttpBodySafetyLimit(int $bodySize, bool $shouldCollect): void + private function traceWithExplicitSpanData(): array { $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 = (GuzzleTracingMiddleware::trace($hub))(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( + $function(new Request('GET', 'https://www.example.com/?token=secret'), [])->wait(); + + return $this->getHttpSpan($transaction)->getData(); + } + + /** + * @return array{array, array} + */ + private function traceConfiguredExchange(): array + { + return $this->traceExchange(['data_collection' => []], $this->configuredRequest(), $this->configuredResponse()); + } + + private function configuredRequest(): Request + { + return new Request( 'POST', - 'https://www.example.com', - ['Content-Type' => 'application/json'], - $requestBody - ), []); - $promise->wait(); - - $this->assertSame(0, $requestBody->tell()); - $this->assertSame(0, $responseBody->tell()); - - $spanData = $this->getHttpSpan($transaction)->getData(); - if ($shouldCollect) { - $this->assertSame('[Filtered]', $spanData['http.request.body.data']); - $this->assertSame('[Filtered]', $spanData['http.response.body.data']); - } else { - $this->assertArrayNotHasKey('http.request.body.data', $spanData); - $this->assertArrayNotHasKey('http.response.body.data', $spanData); - } + 'https://www.example.com/path?search=hello%20world&password=request-secret#fragment', + [ + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer request-secret', + 'Cookie' => 'session_id=request-secret; theme=dark', + ], + '[{"password":"request-secret","name":"Alice"},"unkeyed-secret"]' + ); } - public static function httpBodySafetyLimitDataProvider(): iterable + private function configuredResponse(): Response { - yield 'at 100 KB safety limit' => [100000, true]; - yield 'over 100 KB safety limit' => [100001, false]; + return new Response(200, [ + 'Content-Type' => 'application/x-www-form-urlencoded', + 'X-Response-Id' => 'response-123', + 'Set-Cookie' => [ + 'session_id=response-secret; Path=/; HttpOnly', + 'theme=light; Path=/', + ], + ], 'token=response-secret&status=ok'); } - public function testTraceRespectsDisabledOutgoingHttpDataCollection(): void + /** + * @param array $options + * + * @return array{array, array} + */ + private function traceExchange(array $options, Request $request, Response $response): array { $client = $this->createMock(ClientInterface::class); - $client->expects($this->atLeastOnce()) - ->method('getOptions') - ->willReturn(new Options([ - 'traces_sample_rate' => 1, - 'data_collection' => [ - 'cookies' => ['mode' => 'off'], - 'http_headers' => [ - 'request' => ['mode' => 'off'], - 'response' => ['mode' => 'off'], - ], - 'http_bodies' => [], - 'url_query_params' => ['mode' => 'off'], - ], - ])); - + $client->method('getOptions')->willReturn(new Options($options + ['traces_sample_rate' => 1])); $hub = new Hub($client); SentrySdk::setCurrentHub($hub); - $transaction = $hub->startTransaction(new TransactionContext()); $hub->setSpan($transaction); - - $response = new Response(200, [ - 'Content-Type' => 'application/json', - 'Set-Cookie' => 'session_id=response-secret', - ], '{"token":"response-secret"}'); - $middleware = GuzzleTracingMiddleware::trace($hub); - $function = $middleware(static function () use ($response): PromiseInterface { + $function = (GuzzleTracingMiddleware::trace($hub))(static function () use ($response): PromiseInterface { return new FulfilledPromise($response); }); - /** @var PromiseInterface $promise */ - $promise = $function(new Request( - 'POST', - 'https://www.example.com?password=request-secret', - [ - 'Content-Type' => 'application/json', - 'Cookie' => 'session_id=request-secret', - ], - '{"password":"request-secret"}' - ), []); - $promise->wait(); - - $spanData = $this->getHttpSpan($transaction)->getData(); - $breadcrumbData = $this->getBreadcrumbData($hub); + $function($request, [])->wait(); - foreach ([ - 'http.query', - 'http.request.header.content-type', - 'http.request.header.cookie', - 'http.request.body.data', - 'http.response.header.content-type', - 'http.response.header.set-cookie', - 'http.response.body.data', - ] as $key) { - $this->assertArrayNotHasKey($key, $spanData); - $this->assertArrayNotHasKey($key, $breadcrumbData); - } + return [$this->getHttpSpan($transaction)->getData(), $this->getBreadcrumbData($hub)]; } /** @@ -789,17 +750,6 @@ public static function traceQueryStringDataProvider(): iterable 'search=hello%20world&password=[Filtered]&custom=value', ]; - yield 'collection can be disabled' => [ - [ - 'data_collection' => [ - 'url_query_params' => [ - 'mode' => 'off', - ], - ], - ], - null, - ]; - yield 'allow list filters values not matching configured terms' => [ [ 'data_collection' => [