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",
"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",

View File

@ -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'),
],
];

View File

@ -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);
}
}
}

View File

@ -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,
));
}
}

View File

@ -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 */