From ed1d886f01988efd220c50a3820008c59c865e21 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 10 Oct 2021 22:00:22 +0200 Subject: [PATCH] Added option to disable tracking based on IP address patterns --- composer.json | 1 + config/autoload/tracking.global.php | 3 + module/Core/src/Options/TrackingOptions.php | 29 ++++++++ module/Core/src/Visit/RequestTracker.php | 68 ++++++++++++++++--- module/Core/test/Visit/RequestTrackerTest.php | 18 ++++- 5 files changed, 107 insertions(+), 12 deletions(-) diff --git a/composer.json b/composer.json index 694cb399..f117f3d7 100644 --- a/composer.json +++ b/composer.json @@ -46,6 +46,7 @@ "predis/predis": "^1.1", "pugx/shortid-php": "^0.7", "ramsey/uuid": "^3.9", + "rlanvin/php-ip": "3.0.0-rc2", "shlinkio/shlink-common": "^4.0", "shlinkio/shlink-config": "^1.2", "shlinkio/shlink-event-dispatcher": "^2.1", diff --git a/config/autoload/tracking.global.php b/config/autoload/tracking.global.php index 5ef0eaca..26fe4639 100644 --- a/config/autoload/tracking.global.php +++ b/config/autoload/tracking.global.php @@ -28,6 +28,9 @@ return [ // If true, the user agent will not be tracked 'disable_ua_tracking' => (bool) env('DISABLE_UA_TRACKING', false), + + // A list of IP addresses, patterns or CIDR blocks from which tracking is disabled by default + 'disable_tracking_from' => env('DISABLE_TRACKING_FROM'), ], ]; diff --git a/module/Core/src/Options/TrackingOptions.php b/module/Core/src/Options/TrackingOptions.php index 98e09085..db74b61b 100644 --- a/module/Core/src/Options/TrackingOptions.php +++ b/module/Core/src/Options/TrackingOptions.php @@ -6,6 +6,10 @@ namespace Shlinkio\Shlink\Core\Options; use Laminas\Stdlib\AbstractOptions; +use function array_key_exists; +use function explode; +use function is_array; + class TrackingOptions extends AbstractOptions { private bool $anonymizeRemoteAddr = true; @@ -15,6 +19,7 @@ class TrackingOptions extends AbstractOptions private bool $disableIpTracking = false; private bool $disableReferrerTracking = false; private bool $disableUaTracking = false; + private array $disableTrackingFrom = []; public function anonymizeRemoteAddr(): bool { @@ -41,6 +46,11 @@ class TrackingOptions extends AbstractOptions return $this->disableTrackParam; } + public function queryHasDisableTrackParam(array $query): bool + { + return $this->disableTrackParam !== null && array_key_exists($this->disableTrackParam, $query); + } + protected function setDisableTrackParam(?string $disableTrackParam): void { $this->disableTrackParam = $disableTrackParam; @@ -85,4 +95,23 @@ class TrackingOptions extends AbstractOptions { $this->disableUaTracking = $disableUaTracking; } + + public function disableTrackingFrom(): array + { + return $this->disableTrackingFrom; + } + + public function hasDisableTrackingFrom(): bool + { + return ! empty($this->disableTrackingFrom); + } + + protected function setDisableTrackingFrom(string|array|null $disableTrackingFrom): void + { + if (is_array($disableTrackingFrom)) { + $this->disableTrackingFrom = $disableTrackingFrom; + } else { + $this->disableTrackingFrom = $disableTrackingFrom === null ? [] : explode(',', $disableTrackingFrom); + } + } } diff --git a/module/Core/src/Visit/RequestTracker.php b/module/Core/src/Visit/RequestTracker.php index 3e5bfb51..eee75ea4 100644 --- a/module/Core/src/Visit/RequestTracker.php +++ b/module/Core/src/Visit/RequestTracker.php @@ -5,14 +5,21 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Visit; use Fig\Http\Message\RequestMethodInterface; +use InvalidArgumentException; use Mezzio\Router\Middleware\ImplicitHeadMiddleware; +use PhpIP\IP; use Psr\Http\Message\ServerRequestInterface; +use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Options\TrackingOptions; -use function array_key_exists; +use function explode; +use function Functional\map; +use function Functional\some; +use function implode; +use function str_contains; class RequestTracker implements RequestTrackerInterface, RequestMethodInterface { @@ -37,24 +44,63 @@ class RequestTracker implements RequestTrackerInterface, RequestMethodInterface $notFoundType = $request->getAttribute(NotFoundType::class); $visitor = Visitor::fromRequest($request); - if ($notFoundType?->isBaseUrl()) { - $this->visitsTracker->trackBaseUrlVisit($visitor); - } elseif ($notFoundType?->isRegularNotFound()) { - $this->visitsTracker->trackRegularNotFoundVisit($visitor); - } elseif ($notFoundType?->isInvalidShortUrl()) { - $this->visitsTracker->trackInvalidShortUrlVisit($visitor); - } + match (true) { // @phpstan-ignore-line + $notFoundType?->isBaseUrl() => $this->visitsTracker->trackBaseUrlVisit($visitor), + $notFoundType?->isRegularNotFound() => $this->visitsTracker->trackRegularNotFoundVisit($visitor), + $notFoundType?->isInvalidShortUrl() => $this->visitsTracker->trackInvalidShortUrlVisit($visitor), + }; } private function shouldTrackRequest(ServerRequestInterface $request): bool { - $query = $request->getQueryParams(); - $disableTrackParam = $this->trackingOptions->getDisableTrackParam(); $forwardedMethod = $request->getAttribute(ImplicitHeadMiddleware::FORWARDED_HTTP_METHOD_ATTRIBUTE); if ($forwardedMethod === self::METHOD_HEAD) { return false; } - return $disableTrackParam === null || ! array_key_exists($disableTrackParam, $query); + $remoteAddr = $request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR); + if ($this->shouldDisableTrackingFromAddress($remoteAddr)) { + return false; + } + + $query = $request->getQueryParams(); + return ! $this->trackingOptions->queryHasDisableTrackParam($query); + } + + private function shouldDisableTrackingFromAddress(?string $remoteAddr): bool + { + if ($remoteAddr === null || ! $this->trackingOptions->hasDisableTrackingFrom()) { + return false; + } + + try { + $ip = IP::create($remoteAddr); + } catch (InvalidArgumentException) { + return false; + } + + $remoteAddrParts = explode('.', $remoteAddr); + $disableTrackingFrom = $this->trackingOptions->disableTrackingFrom(); + + return some($disableTrackingFrom, function (string $value) use ($ip, $remoteAddrParts): bool { + try { + return match (true) { + str_contains($value, '*') => $ip->matches($this->parseValueWithWildcards($value, $remoteAddrParts)), + str_contains($value, '/') => $ip->isIn($value), + default => $ip->matches($value), + }; + } catch (InvalidArgumentException) { + return false; + } + }); + } + + private function parseValueWithWildcards(string $value, array $remoteAddrParts): string + { + // Replace wildcard parts with the corresponding ones from the remote address + return implode('.', map( + explode('.', $value), + fn (string $part, int $index) => $part === '*' ? $remoteAddrParts[$index] : $part, + )); } } diff --git a/module/Core/test/Visit/RequestTrackerTest.php b/module/Core/test/Visit/RequestTrackerTest.php index 46faf9fd..144087ad 100644 --- a/module/Core/test/Visit/RequestTrackerTest.php +++ b/module/Core/test/Visit/RequestTrackerTest.php @@ -12,6 +12,7 @@ use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; +use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; use Shlinkio\Shlink\Core\Model\Visitor; @@ -37,7 +38,10 @@ class RequestTrackerTest extends TestCase $this->requestTracker = new RequestTracker( $this->visitsTracker->reveal(), - new TrackingOptions(['disable_track_param' => 'foobar']), + new TrackingOptions([ + 'disable_track_param' => 'foobar', + 'disable_tracking_from' => ['80.90.100.110', '192.168.10.0/24', '1.2.*.*'], + ]), ); $this->request = ServerRequestFactory::fromGlobals()->withAttribute( @@ -69,6 +73,18 @@ class RequestTrackerTest extends TestCase yield 'disable track param as null' => [ ServerRequestFactory::fromGlobals()->withQueryParams(['foobar' => null]), ]; + yield 'exact remote address' => [ServerRequestFactory::fromGlobals()->withAttribute( + IpAddressMiddlewareFactory::REQUEST_ATTR, + '80.90.100.110', + )]; + yield 'matching wildcard remote address' => [ServerRequestFactory::fromGlobals()->withAttribute( + IpAddressMiddlewareFactory::REQUEST_ATTR, + '1.2.3.4', + )]; + yield 'matching CIDR block remote address' => [ServerRequestFactory::fromGlobals()->withAttribute( + IpAddressMiddlewareFactory::REQUEST_ATTR, + '192.168.10.100', + )]; } /** @test */