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

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