Skip to content

Commit

Permalink
Merge pull request #2151 from acelaya-forks/feature/ip-dynamic-redirects
Browse files Browse the repository at this point in the history
Add logic for IP-based dynamic redirects
  • Loading branch information
acelaya authored Jul 18, 2024
2 parents b6b2530 + 9e6cdcb commit 7c65969
Show file tree
Hide file tree
Showing 23 changed files with 457 additions and 79 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this

## [Unreleased]
### Added
* [#2120](https://github.com/shlinkio/shlink/issues/2120) Add new IP address condition for the dynamic rules redirections system.

The conditions allow you to define IP addresses to match as static IP (1.2.3.4), CIDR block (192.168.1.0/24) or wildcard pattern (1.2.\*.\*).

* [#2018](https://github.com/shlinkio/shlink/issues/2018) Add option to allow all short URLs to be unconditionally crawlable in robots.txt, via `ROBOTS_ALLOW_ALL_SHORT_URLS=true` env var, or config option.
* [#2109](https://github.com/shlinkio/shlink/issues/2109) Add option to customize user agents robots.txt, via `ROBOTS_USER_AGENTS=foo,bar,baz` env var, or config option.

Expand Down
4 changes: 2 additions & 2 deletions docs/swagger/definitions/SetShortUrlRedirectRule.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
"properties": {
"type": {
"type": "string",
"enum": ["device", "language", "query-param"],
"description": "The type of the condition, which will condition the logic used to match it"
"enum": ["device", "language", "query-param", "ip-address"],
"description": "The type of the condition, which will determine the logic used to match it"
},
"matchKey": {
"type": ["string", "null"]
Expand Down
3 changes: 3 additions & 0 deletions module/CLI/src/RedirectRule/RedirectRuleHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ private function addRule(ShortUrl $shortUrl, StyleInterface $io, array $currentR
$this->askMandatory('Query param name?', $io),
$this->askOptional('Query param value?', $io),
),
RedirectConditionType::IP_ADDRESS => RedirectCondition::forIpAddress(
$this->askMandatory('IP address, CIDR block or wildcard-pattern (1.2.*.*)', $io),
),
};

$continue = $io->confirm('Do you want to add another condition?');
Expand Down
2 changes: 2 additions & 0 deletions module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ public function newRulesCanBeAdded(
'Language to match?' => 'en-US',
'Query param name?' => 'foo',
'Query param value?' => 'bar',
'IP address, CIDR block or wildcard-pattern (1.2.*.*)' => '1.2.3.4',
default => '',
},
);
Expand Down Expand Up @@ -163,6 +164,7 @@ public static function provideDeviceConditions(): iterable
[RedirectCondition::forQueryParam('foo', 'bar'), RedirectCondition::forQueryParam('foo', 'bar')],
true,
];
yield 'IP address' => [RedirectConditionType::IP_ADDRESS, [RedirectCondition::forIpAddress('1.2.3.4')]];
}

#[Test]
Expand Down
7 changes: 7 additions & 0 deletions module/Core/functions/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
use Laminas\Filter\Word\CamelCaseToSeparator;
use Laminas\Filter\Word\CamelCaseToUnderscore;
use Laminas\InputFilter\InputFilter;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;

Expand Down Expand Up @@ -273,3 +275,8 @@ function splitByComma(?string $value): array

return array_map(trim(...), explode(',', $value));
}

function ipAddressFromRequest(ServerRequestInterface $request): ?string
{
return $request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR);
}
15 changes: 15 additions & 0 deletions module/Core/src/Exception/InvalidIpFormatException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Shlinkio\Shlink\Core\Exception;

use function sprintf;

class InvalidIpFormatException extends RuntimeException implements ExceptionInterface
{
public static function fromInvalidIp(string $ipAddress): self
{
return new self(sprintf('Provided IP %s does not have the right format. Expected X.X.X.X', $ipAddress));
}
}
19 changes: 19 additions & 0 deletions module/Core/src/RedirectRule/Entity/RedirectCondition.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType;
use Shlinkio\Shlink\Core\RedirectRule\Model\Validation\RedirectRulesInputFilter;
use Shlinkio\Shlink\Core\Util\IpAddressUtils;

use function Shlinkio\Shlink\Core\acceptLanguageToLocales;
use function Shlinkio\Shlink\Core\ArrayUtils\some;
use function Shlinkio\Shlink\Core\ipAddressFromRequest;
use function Shlinkio\Shlink\Core\normalizeLocale;
use function Shlinkio\Shlink\Core\splitLocale;
use function sprintf;
Expand Down Expand Up @@ -41,6 +43,15 @@ public static function forDevice(DeviceType $device): self
return new self(RedirectConditionType::DEVICE, $device->value);
}

/**
* @param string $ipAddressPattern - A static IP address (100.200.80.40), CIDR block (192.168.10.0/24) or wildcard
* pattern (11.22.*.*)
*/
public static function forIpAddress(string $ipAddressPattern): self
{
return new self(RedirectConditionType::IP_ADDRESS, $ipAddressPattern);
}

public static function fromRawData(array $rawData): self
{
$type = RedirectConditionType::from($rawData[RedirectRulesInputFilter::CONDITION_TYPE]);
Expand All @@ -59,6 +70,7 @@ public function matchesRequest(ServerRequestInterface $request): bool
RedirectConditionType::QUERY_PARAM => $this->matchesQueryParam($request),
RedirectConditionType::LANGUAGE => $this->matchesLanguage($request),
RedirectConditionType::DEVICE => $this->matchesDevice($request),
RedirectConditionType::IP_ADDRESS => $this->matchesRemoteIpAddress($request),
};
}

Expand Down Expand Up @@ -100,6 +112,12 @@ private function matchesDevice(ServerRequestInterface $request): bool
return $device !== null && $device->value === strtolower($this->matchValue);
}

private function matchesRemoteIpAddress(ServerRequestInterface $request): bool
{
$remoteAddress = ipAddressFromRequest($request);
return $remoteAddress !== null && IpAddressUtils::ipAddressMatchesGroups($remoteAddress, [$this->matchValue]);
}

public function jsonSerialize(): array
{
return [
Expand All @@ -119,6 +137,7 @@ public function toHumanFriendly(): string
$this->matchKey,
$this->matchValue,
),
RedirectConditionType::IP_ADDRESS => sprintf('IP address matches %s', $this->matchValue),
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ enum RedirectConditionType: string
case DEVICE = 'device';
case LANGUAGE = 'language';
case QUERY_PARAM = 'query-param';
case IP_ADDRESS = 'ip-address';
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use Shlinkio\Shlink\Core\Util\IpAddressUtils;

use function Shlinkio\Shlink\Core\ArrayUtils\contains;
use function Shlinkio\Shlink\Core\enumValues;
Expand Down Expand Up @@ -71,13 +72,14 @@ private static function createRedirectConditionInputFilter(): InputFilter
$redirectConditionInputFilter->add($type);

$value = InputFactory::basic(self::CONDITION_MATCH_VALUE, required: true);
$value->getValidatorChain()->attach(new Callback(function (string $value, array $context) {
if ($context[self::CONDITION_TYPE] === RedirectConditionType::DEVICE->value) {
return contains($value, enumValues(DeviceType::class));
}

return true;
}));
$value->getValidatorChain()->attach(new Callback(
fn (string $value, array $context) => match ($context[self::CONDITION_TYPE]) {
RedirectConditionType::DEVICE->value => contains($value, enumValues(DeviceType::class)),
RedirectConditionType::IP_ADDRESS->value => IpAddressUtils::isStaticIpCidrOrWildcard($value),
// RedirectConditionType::LANGUAGE->value => TODO,
default => true,
},
));
$redirectConditionInputFilter->add($value);

$redirectConditionInputFilter->add(
Expand Down
85 changes: 85 additions & 0 deletions module/Core/src/Util/IpAddressUtils.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

declare(strict_types=1);

namespace Shlinkio\Shlink\Core\Util;

use IPLib\Address\IPv4;
use IPLib\Factory;
use IPLib\Range\RangeInterface;
use Shlinkio\Shlink\Core\Exception\InvalidIpFormatException;

use function array_keys;
use function array_map;
use function explode;
use function implode;
use function Shlinkio\Shlink\Core\ArrayUtils\some;
use function str_contains;

final class IpAddressUtils
{
public static function isStaticIpCidrOrWildcard(string $candidate): bool
{
return self::candidateToRange($candidate, ['0', '0', '0', '0']) !== null;
}

/**
* Checks if an IP address matches any of provided groups.
* Every group can be a static IP address (100.200.80.40), a CIDR block (192.168.10.0/24) or a wildcard pattern
* (11.22.*.*).
*
* Matching will happen as follows:
* * Static IP address -> strict equality with provided IP address.
* * CIDR block -> provided IP address is part of that block.
* * Wildcard pattern -> static parts match the corresponding ones in provided IP address.
*
* @param string[] $groups
* @throws InvalidIpFormatException
*/
public static function ipAddressMatchesGroups(string $ipAddress, array $groups): bool
{
$ip = IPv4::parseString($ipAddress);
if ($ip === null) {
throw InvalidIpFormatException::fromInvalidIp($ipAddress);
}

$ipAddressParts = explode('.', $ipAddress);

return some($groups, function (string $group) use ($ip, $ipAddressParts): bool {
$range = self::candidateToRange($group, $ipAddressParts);
return $range !== null && $range->contains($ip);
});
}

/**
* Convert a static IP, CIDR block or wildcard pattern into a Range object
*
* @param string[] $ipAddressParts
*/
private static function candidateToRange(string $candidate, array $ipAddressParts): ?RangeInterface
{
return str_contains($candidate, '*')
? self::parseValueWithWildcards($candidate, $ipAddressParts)
: Factory::parseRangeString($candidate);
}

/**
* Try to generate an IP range from a wildcard pattern.
* Factory::parseRangeString can usually do this automatically, but only if wildcards are at the end. This also
* covers cases where wildcards are in between.
*/
private static function parseValueWithWildcards(string $value, array $ipAddressParts): ?RangeInterface
{
$octets = explode('.', $value);
$keys = array_keys($octets);

// Replace wildcard parts with the corresponding ones from the remote address
return Factory::parseRangeString(
implode('.', array_map(
fn (string $part, int $index) => $part === '*' ? $ipAddressParts[$index] : $part,
$octets,
$keys,
)),
);
}
}
4 changes: 2 additions & 2 deletions module/Core/src/Visit/Model/Visitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
namespace Shlinkio\Shlink\Core\Visit\Model;

use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
use Shlinkio\Shlink\Core\Options\TrackingOptions;

use function Shlinkio\Shlink\Core\ipAddressFromRequest;
use function Shlinkio\Shlink\Core\isCrawler;
use function substr;

Expand Down Expand Up @@ -46,7 +46,7 @@ public static function fromRequest(ServerRequestInterface $request): self
return new self(
$request->getHeaderLine('User-Agent'),
$request->getHeaderLine('Referer'),
$request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR),
ipAddressFromRequest($request),
$request->getUri()->__toString(),
);
}
Expand Down
54 changes: 10 additions & 44 deletions module/Core/src/Visit/RequestTracker.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,21 @@
namespace Shlinkio\Shlink\Core\Visit;

use Fig\Http\Message\RequestMethodInterface;
use IPLib\Address\IPv4;
use IPLib\Factory;
use IPLib\Range\RangeInterface;
use Mezzio\Router\Middleware\ImplicitHeadMiddleware;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
use Shlinkio\Shlink\Core\Exception\InvalidIpFormatException;
use Shlinkio\Shlink\Core\Options\TrackingOptions;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Util\IpAddressUtils;
use Shlinkio\Shlink\Core\Visit\Model\Visitor;

use function array_keys;
use function array_map;
use function explode;
use function implode;
use function Shlinkio\Shlink\Core\ArrayUtils\some;
use function str_contains;
use function Shlinkio\Shlink\Core\ipAddressFromRequest;

class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
readonly class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
{
public function __construct(
private readonly VisitsTrackerInterface $visitsTracker,
private readonly TrackingOptions $trackingOptions,
) {
public function __construct(private VisitsTrackerInterface $visitsTracker, private TrackingOptions $trackingOptions)
{
}

public function trackIfApplicable(ShortUrl $shortUrl, ServerRequestInterface $request): void
Expand Down Expand Up @@ -63,7 +54,7 @@ private function shouldTrackRequest(ServerRequestInterface $request): bool
return false;
}

$remoteAddr = $request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR);
$remoteAddr = ipAddressFromRequest($request);
if ($this->shouldDisableTrackingFromAddress($remoteAddr)) {
return false;
}
Expand All @@ -78,35 +69,10 @@ private function shouldDisableTrackingFromAddress(?string $remoteAddr): bool
return false;
}

$ip = IPv4::parseString($remoteAddr);
if ($ip === null) {
try {
return IpAddressUtils::ipAddressMatchesGroups($remoteAddr, $this->trackingOptions->disableTrackingFrom);
} catch (InvalidIpFormatException) {
return false;
}

$remoteAddrParts = explode('.', $remoteAddr);
$disableTrackingFrom = $this->trackingOptions->disableTrackingFrom;

return some($disableTrackingFrom, function (string $value) use ($ip, $remoteAddrParts): bool {
$range = str_contains($value, '*')
? $this->parseValueWithWildcards($value, $remoteAddrParts)
: Factory::parseRangeString($value);

return $range !== null && $ip->matches($range);
});
}

private function parseValueWithWildcards(string $value, array $remoteAddrParts): ?RangeInterface
{
$octets = explode('.', $value);
$keys = array_keys($octets);

// Replace wildcard parts with the corresponding ones from the remote address
return Factory::parseRangeString(
implode('.', array_map(
fn (string $part, int $index) => $part === '*' ? $remoteAddrParts[$index] : $part,
$octets,
$keys,
)),
);
}
}
12 changes: 12 additions & 0 deletions module/Core/test-api/Action/RedirectTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
use PHPUnit\Framework\Attributes\TestWith;
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;

use function sprintf;

use const ShlinkioTest\Shlink\ANDROID_USER_AGENT;
use const ShlinkioTest\Shlink\DESKTOP_USER_AGENT;
use const ShlinkioTest\Shlink\IOS_USER_AGENT;
Expand Down Expand Up @@ -86,6 +88,16 @@ public static function provideRequestOptions(): iterable
],
'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/',
];

$clientDetection = require __DIR__ . '/../../../../config/autoload/client-detection.global.php';
foreach ($clientDetection['ip_address_resolution']['headers_to_inspect'] as $header) {
yield sprintf('rule: IP address in "%s" header', $header) => [
[
RequestOptions::HEADERS => [$header => '1.2.3.4'],
],
'https://example.com/static-ip-address',
];
}
}

/**
Expand Down
Loading

0 comments on commit 7c65969

Please sign in to comment.