vendor/sentry/sentry/src/Integration/RequestIntegration.php line 117

Open in your IDE?
  1. <?php
  2. declare(strict_types=1);
  3. namespace Sentry\Integration;
  4. use Psr\Http\Message\ServerRequestInterface;
  5. use Psr\Http\Message\UploadedFileInterface;
  6. use Sentry\Event;
  7. use Sentry\Exception\JsonException;
  8. use Sentry\Options;
  9. use Sentry\SentrySdk;
  10. use Sentry\State\Scope;
  11. use Sentry\UserDataBag;
  12. use Sentry\Util\JSON;
  13. use Symfony\Component\OptionsResolver\Options as SymfonyOptions;
  14. use Symfony\Component\OptionsResolver\OptionsResolver;
  15. /**
  16.  * This integration collects information from the request and attaches them to
  17.  * the event.
  18.  *
  19.  * @author Stefano Arlandini <sarlandini@alice.it>
  20.  */
  21. final class RequestIntegration implements IntegrationInterface
  22. {
  23.     /**
  24.      * This constant represents the size limit in bytes beyond which the body
  25.      * of the request is not captured when the `max_request_body_size` option
  26.      * is set to `small`.
  27.      */
  28.     private const REQUEST_BODY_SMALL_MAX_CONTENT_LENGTH 10 ** 3;
  29.     /**
  30.      * This constant represents the size limit in bytes beyond which the body
  31.      * of the request is not captured when the `max_request_body_size` option
  32.      * is set to `medium`.
  33.      */
  34.     private const REQUEST_BODY_MEDIUM_MAX_CONTENT_LENGTH 10 ** 4;
  35.     /**
  36.      * This constant is a map of maximum allowed sizes for each value of the
  37.      * `max_request_body_size` option.
  38.      *
  39.      * @deprecated The 'none' option is deprecated since version 3.10, to be removed in 4.0
  40.      */
  41.     private const MAX_REQUEST_BODY_SIZE_OPTION_TO_MAX_LENGTH_MAP = [
  42.         'none' => 0,
  43.         'never' => 0,
  44.         'small' => self::REQUEST_BODY_SMALL_MAX_CONTENT_LENGTH,
  45.         'medium' => self::REQUEST_BODY_MEDIUM_MAX_CONTENT_LENGTH,
  46.         'always' => \PHP_INT_MAX,
  47.     ];
  48.     /**
  49.      * This constant defines the default list of headers that may contain
  50.      * sensitive data and that will be sanitized if sending PII is disabled.
  51.      */
  52.     private const DEFAULT_SENSITIVE_HEADERS = [
  53.         'Authorization',
  54.         'Cookie',
  55.         'Set-Cookie',
  56.         'X-Forwarded-For',
  57.         'X-Real-IP',
  58.     ];
  59.     /**
  60.      * @var RequestFetcherInterface PSR-7 request fetcher
  61.      */
  62.     private $requestFetcher;
  63.     /**
  64.      * @var array<string, mixed> The options
  65.      *
  66.      * @psalm-var array{
  67.      *     pii_sanitize_headers: string[]
  68.      * }
  69.      */
  70.     private $options;
  71.     /**
  72.      * Constructor.
  73.      *
  74.      * @param RequestFetcherInterface|null $requestFetcher PSR-7 request fetcher
  75.      * @param array<string, mixed>         $options        The options
  76.      *
  77.      * @psalm-param array{
  78.      *     pii_sanitize_headers?: string[]
  79.      * } $options
  80.      */
  81.     public function __construct(?RequestFetcherInterface $requestFetcher null, array $options = [])
  82.     {
  83.         $resolver = new OptionsResolver();
  84.         $this->configureOptions($resolver);
  85.         $this->requestFetcher $requestFetcher ?? new RequestFetcher();
  86.         $this->options $resolver->resolve($options);
  87.     }
  88.     /**
  89.      * {@inheritdoc}
  90.      */
  91.     public function setupOnce(): void
  92.     {
  93.         Scope::addGlobalEventProcessor(function (Event $event): Event {
  94.             $currentHub SentrySdk::getCurrentHub();
  95.             $integration $currentHub->getIntegration(self::class);
  96.             $client $currentHub->getClient();
  97.             // The client bound to the current hub, if any, could not have this
  98.             // integration enabled. If this is the case, bail out
  99.             if (null === $integration || null === $client) {
  100.                 return $event;
  101.             }
  102.             $this->processEvent($event$client->getOptions());
  103.             return $event;
  104.         });
  105.     }
  106.     private function processEvent(Event $eventOptions $options): void
  107.     {
  108.         $request $this->requestFetcher->fetchRequest();
  109.         if (null === $request) {
  110.             return;
  111.         }
  112.         $requestData = [
  113.             'url' => (string) $request->getUri(),
  114.             'method' => $request->getMethod(),
  115.         ];
  116.         if ($request->getUri()->getQuery()) {
  117.             $requestData['query_string'] = $request->getUri()->getQuery();
  118.         }
  119.         if ($options->shouldSendDefaultPii()) {
  120.             $serverParams $request->getServerParams();
  121.             if (isset($serverParams['REMOTE_ADDR'])) {
  122.                 $user $event->getUser();
  123.                 $requestData['env']['REMOTE_ADDR'] = $serverParams['REMOTE_ADDR'];
  124.                 if (null === $user) {
  125.                     $user UserDataBag::createFromUserIpAddress($serverParams['REMOTE_ADDR']);
  126.                 } elseif (null === $user->getIpAddress()) {
  127.                     $user->setIpAddress($serverParams['REMOTE_ADDR']);
  128.                 }
  129.                 $event->setUser($user);
  130.             }
  131.             $requestData['cookies'] = $request->getCookieParams();
  132.             $requestData['headers'] = $request->getHeaders();
  133.         } else {
  134.             $requestData['headers'] = $this->sanitizeHeaders($request->getHeaders());
  135.         }
  136.         $requestBody $this->captureRequestBody($options$request);
  137.         if (!empty($requestBody)) {
  138.             $requestData['data'] = $requestBody;
  139.         }
  140.         $event->setRequest($requestData);
  141.     }
  142.     /**
  143.      * Removes headers containing potential PII.
  144.      *
  145.      * @param array<array-key, string[]> $headers Array containing request headers
  146.      *
  147.      * @return array<string, string[]>
  148.      */
  149.     private function sanitizeHeaders(array $headers): array
  150.     {
  151.         foreach ($headers as $name => $values) {
  152.             // Cast the header name into a string, to avoid errors on numeric headers
  153.             $name = (string) $name;
  154.             if (!\in_array(strtolower($name), $this->options['pii_sanitize_headers'], true)) {
  155.                 continue;
  156.             }
  157.             foreach ($values as $headerLine => $headerValue) {
  158.                 $headers[$name][$headerLine] = '[Filtered]';
  159.             }
  160.         }
  161.         return $headers;
  162.     }
  163.     /**
  164.      * Gets the decoded body of the request, if available. If the Content-Type
  165.      * header contains "application/json" then the content is decoded and if
  166.      * the parsing fails then the raw data is returned. If there are submitted
  167.      * fields or files, all of their information are parsed and returned.
  168.      *
  169.      * @param Options                $options The options of the client
  170.      * @param ServerRequestInterface $request The server request
  171.      *
  172.      * @return mixed
  173.      */
  174.     private function captureRequestBody(Options $optionsServerRequestInterface $request)
  175.     {
  176.         $maxRequestBodySize $options->getMaxRequestBodySize();
  177.         $requestBodySize = (int) $request->getHeaderLine('Content-Length');
  178.         if (!$this->isRequestBodySizeWithinReadBounds($requestBodySize$maxRequestBodySize)) {
  179.             return null;
  180.         }
  181.         $requestData $request->getParsedBody();
  182.         $requestData array_replace(
  183.             $this->parseUploadedFiles($request->getUploadedFiles()),
  184.             \is_array($requestData) ? $requestData : []
  185.         );
  186.         if (!empty($requestData)) {
  187.             return $requestData;
  188.         }
  189.         $requestBody '';
  190.         $maxLength self::MAX_REQUEST_BODY_SIZE_OPTION_TO_MAX_LENGTH_MAP[$maxRequestBodySize];
  191.         if ($maxLength) {
  192.             $stream $request->getBody();
  193.             while ($maxLength && !$stream->eof()) {
  194.                 if ('' === $buffer $stream->read(min($maxLengthself::REQUEST_BODY_MEDIUM_MAX_CONTENT_LENGTH))) {
  195.                     break;
  196.                 }
  197.                 $requestBody .= $buffer;
  198.                 $maxLength -= \strlen($buffer);
  199.             }
  200.         }
  201.         if ('application/json' === $request->getHeaderLine('Content-Type')) {
  202.             try {
  203.                 return JSON::decode($requestBody);
  204.             } catch (JsonException $exception) {
  205.                 // Fallback to returning the raw data from the request body
  206.             }
  207.         }
  208.         return $requestBody;
  209.     }
  210.     /**
  211.      * Create an array with the same structure as $uploadedFiles, but replacing
  212.      * each UploadedFileInterface with an array of info.
  213.      *
  214.      * @param array<string, mixed> $uploadedFiles The uploaded files info from a PSR-7 server request
  215.      *
  216.      * @return array<string, mixed>
  217.      */
  218.     private function parseUploadedFiles(array $uploadedFiles): array
  219.     {
  220.         $result = [];
  221.         foreach ($uploadedFiles as $key => $item) {
  222.             if ($item instanceof UploadedFileInterface) {
  223.                 $result[$key] = [
  224.                     'client_filename' => $item->getClientFilename(),
  225.                     'client_media_type' => $item->getClientMediaType(),
  226.                     'size' => $item->getSize(),
  227.                 ];
  228.             } elseif (\is_array($item)) {
  229.                 $result[$key] = $this->parseUploadedFiles($item);
  230.             } else {
  231.                 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)));
  232.             }
  233.         }
  234.         return $result;
  235.     }
  236.     private function isRequestBodySizeWithinReadBounds(int $requestBodySizestring $maxRequestBodySize): bool
  237.     {
  238.         if ($requestBodySize <= 0) {
  239.             return false;
  240.         }
  241.         if ('none' === $maxRequestBodySize || 'never' === $maxRequestBodySize) {
  242.             return false;
  243.         }
  244.         if ('small' === $maxRequestBodySize && $requestBodySize self::REQUEST_BODY_SMALL_MAX_CONTENT_LENGTH) {
  245.             return false;
  246.         }
  247.         if ('medium' === $maxRequestBodySize && $requestBodySize self::REQUEST_BODY_MEDIUM_MAX_CONTENT_LENGTH) {
  248.             return false;
  249.         }
  250.         return true;
  251.     }
  252.     /**
  253.      * Configures the options of the client.
  254.      *
  255.      * @param OptionsResolver $resolver The resolver for the options
  256.      */
  257.     private function configureOptions(OptionsResolver $resolver): void
  258.     {
  259.         $resolver->setDefault('pii_sanitize_headers'self::DEFAULT_SENSITIVE_HEADERS);
  260.         $resolver->setAllowedTypes('pii_sanitize_headers''string[]');
  261.         $resolver->setNormalizer('pii_sanitize_headers', static function (SymfonyOptions $options, array $value): array {
  262.             return array_map('strtolower'$value);
  263.         });
  264.     }
  265. }