Added option to disable tracking based on IP address patterns

This commit is contained in:
Alejandro Celaya 2021-10-10 22:00:22 +02:00
parent db98d811b0
commit ed1d886f01
5 changed files with 107 additions and 12 deletions

View File

@ -46,6 +46,7 @@
"predis/predis": "^1.1", "predis/predis": "^1.1",
"pugx/shortid-php": "^0.7", "pugx/shortid-php": "^0.7",
"ramsey/uuid": "^3.9", "ramsey/uuid": "^3.9",
"rlanvin/php-ip": "3.0.0-rc2",
"shlinkio/shlink-common": "^4.0", "shlinkio/shlink-common": "^4.0",
"shlinkio/shlink-config": "^1.2", "shlinkio/shlink-config": "^1.2",
"shlinkio/shlink-event-dispatcher": "^2.1", "shlinkio/shlink-event-dispatcher": "^2.1",

View File

@ -28,6 +28,9 @@ return [
// If true, the user agent will not be tracked // If true, the user agent will not be tracked
'disable_ua_tracking' => (bool) env('DISABLE_UA_TRACKING', false), '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'),
], ],
]; ];

View File

@ -6,6 +6,10 @@ namespace Shlinkio\Shlink\Core\Options;
use Laminas\Stdlib\AbstractOptions; use Laminas\Stdlib\AbstractOptions;
use function array_key_exists;
use function explode;
use function is_array;
class TrackingOptions extends AbstractOptions class TrackingOptions extends AbstractOptions
{ {
private bool $anonymizeRemoteAddr = true; private bool $anonymizeRemoteAddr = true;
@ -15,6 +19,7 @@ class TrackingOptions extends AbstractOptions
private bool $disableIpTracking = false; private bool $disableIpTracking = false;
private bool $disableReferrerTracking = false; private bool $disableReferrerTracking = false;
private bool $disableUaTracking = false; private bool $disableUaTracking = false;
private array $disableTrackingFrom = [];
public function anonymizeRemoteAddr(): bool public function anonymizeRemoteAddr(): bool
{ {
@ -41,6 +46,11 @@ class TrackingOptions extends AbstractOptions
return $this->disableTrackParam; 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 protected function setDisableTrackParam(?string $disableTrackParam): void
{ {
$this->disableTrackParam = $disableTrackParam; $this->disableTrackParam = $disableTrackParam;
@ -85,4 +95,23 @@ class TrackingOptions extends AbstractOptions
{ {
$this->disableUaTracking = $disableUaTracking; $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);
}
}
} }

View File

@ -5,14 +5,21 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit; namespace Shlinkio\Shlink\Core\Visit;
use Fig\Http\Message\RequestMethodInterface; use Fig\Http\Message\RequestMethodInterface;
use InvalidArgumentException;
use Mezzio\Router\Middleware\ImplicitHeadMiddleware; use Mezzio\Router\Middleware\ImplicitHeadMiddleware;
use PhpIP\IP;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Options\TrackingOptions; 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 class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
{ {
@ -37,24 +44,63 @@ class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
$notFoundType = $request->getAttribute(NotFoundType::class); $notFoundType = $request->getAttribute(NotFoundType::class);
$visitor = Visitor::fromRequest($request); $visitor = Visitor::fromRequest($request);
if ($notFoundType?->isBaseUrl()) { match (true) { // @phpstan-ignore-line
$this->visitsTracker->trackBaseUrlVisit($visitor); $notFoundType?->isBaseUrl() => $this->visitsTracker->trackBaseUrlVisit($visitor),
} elseif ($notFoundType?->isRegularNotFound()) { $notFoundType?->isRegularNotFound() => $this->visitsTracker->trackRegularNotFoundVisit($visitor),
$this->visitsTracker->trackRegularNotFoundVisit($visitor); $notFoundType?->isInvalidShortUrl() => $this->visitsTracker->trackInvalidShortUrlVisit($visitor),
} elseif ($notFoundType?->isInvalidShortUrl()) { };
$this->visitsTracker->trackInvalidShortUrlVisit($visitor);
}
} }
private function shouldTrackRequest(ServerRequestInterface $request): bool private function shouldTrackRequest(ServerRequestInterface $request): bool
{ {
$query = $request->getQueryParams();
$disableTrackParam = $this->trackingOptions->getDisableTrackParam();
$forwardedMethod = $request->getAttribute(ImplicitHeadMiddleware::FORWARDED_HTTP_METHOD_ATTRIBUTE); $forwardedMethod = $request->getAttribute(ImplicitHeadMiddleware::FORWARDED_HTTP_METHOD_ATTRIBUTE);
if ($forwardedMethod === self::METHOD_HEAD) { if ($forwardedMethod === self::METHOD_HEAD) {
return false; 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,
));
} }
} }

View File

@ -12,6 +12,7 @@ use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Model\Visitor;
@ -37,7 +38,10 @@ class RequestTrackerTest extends TestCase
$this->requestTracker = new RequestTracker( $this->requestTracker = new RequestTracker(
$this->visitsTracker->reveal(), $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( $this->request = ServerRequestFactory::fromGlobals()->withAttribute(
@ -69,6 +73,18 @@ class RequestTrackerTest extends TestCase
yield 'disable track param as null' => [ yield 'disable track param as null' => [
ServerRequestFactory::fromGlobals()->withQueryParams(['foobar' => 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 */ /** @test */