Merge pull request #2151 from acelaya-forks/feature/ip-dynamic-redirects

Add logic for IP-based dynamic redirects
This commit is contained in:
Alejandro Celaya 2024-07-18 21:32:24 +02:00 committed by GitHub
commit 7c659699f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 457 additions and 79 deletions

View File

@ -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.

View File

@ -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"]

View File

@ -108,6 +108,9 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface
$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?');

View File

@ -116,6 +116,7 @@ class RedirectRuleHandlerTest extends TestCase
'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 => '',
},
);
@ -163,6 +164,7 @@ class RedirectRuleHandlerTest extends TestCase
[RedirectCondition::forQueryParam('foo', 'bar'), RedirectCondition::forQueryParam('foo', 'bar')],
true,
];
yield 'IP address' => [RedirectConditionType::IP_ADDRESS, [RedirectCondition::forIpAddress('1.2.3.4')]];
}
#[Test]

View File

@ -14,6 +14,8 @@ use Jaybizzle\CrawlerDetect\CrawlerDetect;
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;
@ -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);
}

View File

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

View File

@ -8,9 +8,11 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity;
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;
@ -41,6 +43,15 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
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]);
@ -59,6 +70,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
RedirectConditionType::QUERY_PARAM => $this->matchesQueryParam($request),
RedirectConditionType::LANGUAGE => $this->matchesLanguage($request),
RedirectConditionType::DEVICE => $this->matchesDevice($request),
RedirectConditionType::IP_ADDRESS => $this->matchesRemoteIpAddress($request),
};
}
@ -100,6 +112,12 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
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 [
@ -119,6 +137,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
$this->matchKey,
$this->matchValue,
),
RedirectConditionType::IP_ADDRESS => sprintf('IP address matches %s', $this->matchValue),
};
}
}

View File

@ -7,4 +7,5 @@ enum RedirectConditionType: string
case DEVICE = 'device';
case LANGUAGE = 'language';
case QUERY_PARAM = 'query-param';
case IP_ADDRESS = 'ip-address';
}

View File

@ -12,6 +12,7 @@ use Shlinkio\Shlink\Common\Validation\InputFactory;
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;
@ -71,13 +72,14 @@ class RedirectRulesInputFilter extends 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(

View File

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

View File

@ -5,9 +5,9 @@ declare(strict_types=1);
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;
@ -46,7 +46,7 @@ final class Visitor
return new self(
$request->getHeaderLine('User-Agent'),
$request->getHeaderLine('Referer'),
$request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR),
ipAddressFromRequest($request),
$request->getUri()->__toString(),
);
}

View File

@ -5,30 +5,21 @@ declare(strict_types=1);
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
@ -63,7 +54,7 @@ class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
return false;
}
$remoteAddr = $request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR);
$remoteAddr = ipAddressFromRequest($request);
if ($this->shouldDisableTrackingFromAddress($remoteAddr)) {
return false;
}
@ -78,35 +69,10 @@ class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
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,
)),
);
}
}

View File

@ -10,6 +10,8 @@ use PHPUnit\Framework\Attributes\Test;
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;
@ -86,6 +88,16 @@ class RedirectTest extends ApiTestCase
],
'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',
];
}
}
/**

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Exception;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Exception\InvalidIpFormatException;
class InvalidIpFormatExceptionTest extends TestCase
{
#[Test]
public function fromInvalidIp(): void
{
$e = InvalidIpFormatException::fromInvalidIp('invalid');
self::assertEquals('Provided IP invalid does not have the right format. Expected X.X.X.X', $e->getMessage());
}
}

View File

@ -6,6 +6,7 @@ use Laminas\Diactoros\ServerRequestFactory;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition;
@ -28,19 +29,19 @@ class RedirectConditionTest extends TestCase
}
#[Test]
#[TestWith([null, '', false])] // no accept language
#[TestWith(['', '', false])] // empty accept language
#[TestWith(['*', '', false])] // wildcard accept language
#[TestWith(['en', 'en', true])] // single language match
#[TestWith(['es, en,fr', 'en', true])] // multiple languages match
#[TestWith(['es, en-US,fr', 'EN', true])] // multiple locales match
#[TestWith(['es_ES', 'es-ES', true])] // single locale match
#[TestWith(['en-US,es-ES;q=0.6', 'es-ES', false])] // too low quality
#[TestWith(['en-US,es-ES;q=0.9', 'es-ES', true])] // quality high enough
#[TestWith(['en-UK', 'en-uk', true])] // different casing match
#[TestWith(['en-UK', 'en', true])] // only lang
#[TestWith(['es-AR', 'en', false])] // different only lang
#[TestWith(['fr', 'fr-FR', false])] // less restrictive matching locale
#[TestWith([null, '', false], 'no accept language')]
#[TestWith(['', '', false], 'empty accept language')]
#[TestWith(['*', '', false], 'wildcard accept language')]
#[TestWith(['en', 'en', true], 'single language match')]
#[TestWith(['es, en,fr', 'en', true], 'multiple languages match')]
#[TestWith(['es, en-US,fr', 'EN', true], 'multiple locales match')]
#[TestWith(['es_ES', 'es-ES', true], 'single locale match')]
#[TestWith(['en-US,es-ES;q=0.6', 'es-ES', false], 'too low quality')]
#[TestWith(['en-US,es-ES;q=0.9', 'es-ES', true], 'quality high enough')]
#[TestWith(['en-UK', 'en-uk', true], 'different casing match')]
#[TestWith(['en-UK', 'en', true], 'only lang')]
#[TestWith(['es-AR', 'en', false], 'different only lang')]
#[TestWith(['fr', 'fr-FR', false], 'less restrictive matching locale')]
public function matchesLanguage(?string $acceptLanguage, string $value, bool $expected): void
{
$request = ServerRequestFactory::fromGlobals();
@ -72,4 +73,24 @@ class RedirectConditionTest extends TestCase
self::assertEquals($expected, $result);
}
#[Test]
#[TestWith([null, '1.2.3.4', false], 'no remote IP address')]
#[TestWith(['1.2.3.4', '1.2.3.4', true], 'static IP address match')]
#[TestWith(['4.3.2.1', '1.2.3.4', false], 'no static IP address match')]
#[TestWith(['192.168.1.35', '192.168.1.0/24', true], 'CIDR block match')]
#[TestWith(['1.2.3.4', '192.168.1.0/24', false], 'no CIDR block match')]
#[TestWith(['192.168.1.35', '192.168.1.*', true], 'wildcard pattern match')]
#[TestWith(['1.2.3.4', '192.168.1.*', false], 'no wildcard pattern match')]
public function matchesRemoteIpAddress(?string $remoteIp, string $ipToMatch, bool $expected): void
{
$request = ServerRequestFactory::fromGlobals();
if ($remoteIp !== null) {
$request = $request->withAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR, $remoteIp);
}
$result = RedirectCondition::forIpAddress($ipToMatch)->matchesRequest($request);
self::assertEquals($expected, $result);
}
}

View File

@ -1,17 +1,20 @@
<?php
namespace RedirectRule\Entity;
namespace ShlinkioTest\Shlink\Core\RedirectRule\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Laminas\Diactoros\ServerRequestFactory;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition;
use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule;
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use function sprintf;
class ShortUrlRedirectRuleTest extends TestCase
{
#[Test, DataProvider('provideConditions')]
@ -55,9 +58,12 @@ class ShortUrlRedirectRuleTest extends TestCase
#[Test, DataProvider('provideConditionMappingCallbacks')]
public function conditionsCanBeMapped(callable $callback, array $expectedResult): void
{
$conditions = new ArrayCollection(
[RedirectCondition::forLanguage('en-UK'), RedirectCondition::forQueryParam('foo', 'bar')],
);
$conditions = new ArrayCollection([
RedirectCondition::forLanguage('en-UK'),
RedirectCondition::forQueryParam('foo', 'bar'),
RedirectCondition::forDevice(DeviceType::ANDROID),
RedirectCondition::forIpAddress('1.2.3.*'),
]);
$rule = $this->createRule($conditions);
$result = $rule->mapConditions($callback);
@ -78,10 +84,22 @@ class ShortUrlRedirectRuleTest extends TestCase
'matchKey' => 'foo',
'matchValue' => 'bar',
],
[
'type' => RedirectConditionType::DEVICE->value,
'matchKey' => null,
'matchValue' => DeviceType::ANDROID->value,
],
[
'type' => RedirectConditionType::IP_ADDRESS->value,
'matchKey' => null,
'matchValue' => '1.2.3.*',
],
]];
yield 'human-friendly conditions' => [fn (RedirectCondition $cond) => $cond->toHumanFriendly(), [
'en-UK language is accepted',
'query string contains foo=bar',
sprintf('device is %s', DeviceType::ANDROID->value),
'IP address matches 1.2.3.*',
]];
}

View File

@ -51,9 +51,76 @@ class RedirectRulesDataTest extends TestCase
],
],
]]])]
#[TestWith([['redirectRules' => [
[
'longUrl' => 'https://example.com',
'conditions' => [
[
'type' => 'ip-address',
'matchKey' => null,
'matchValue' => 'not an IP address',
],
],
],
]]])]
public function throwsWhenProvidedDataIsInvalid(array $invalidData): void
{
$this->expectException(ValidationException::class);
RedirectRulesData::fromRawData($invalidData);
}
#[Test]
#[TestWith([['redirectRules' => [
[
'longUrl' => 'https://example.com',
'conditions' => [
[
'type' => 'ip-address',
'matchKey' => null,
'matchValue' => '1.2.3.4',
],
],
],
]]], 'static IP')]
#[TestWith([['redirectRules' => [
[
'longUrl' => 'https://example.com',
'conditions' => [
[
'type' => 'ip-address',
'matchKey' => null,
'matchValue' => '1.2.3.0/24',
],
],
],
]]], 'CIDR block')]
#[TestWith([['redirectRules' => [
[
'longUrl' => 'https://example.com',
'conditions' => [
[
'type' => 'ip-address',
'matchKey' => null,
'matchValue' => '1.2.3.*',
],
],
],
]]], 'IP wildcard pattern')]
#[TestWith([['redirectRules' => [
[
'longUrl' => 'https://example.com',
'conditions' => [
[
'type' => 'ip-address',
'matchKey' => null,
'matchValue' => '1.2.*.4',
],
],
],
]]], 'in-between IP wildcard pattern')]
public function allowsValidDataToBeSet(array $validData): void
{
$result = RedirectRulesData::fromRawData($validData);
self::assertEquals($result->rules, $validData['redirectRules']);
}
}

View File

@ -9,6 +9,7 @@ use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition;
use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule;
@ -88,5 +89,30 @@ class ShortUrlRedirectionResolverTest extends TestCase
RedirectCondition::forQueryParam('foo', 'bar'),
'https://example.com/from-rule',
];
yield 'matching static IP address' => [
$request()->withAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR, '1.2.3.4'),
RedirectCondition::forIpAddress('1.2.3.4'),
'https://example.com/from-rule',
];
yield 'matching CIDR block' => [
$request()->withAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR, '192.168.1.35'),
RedirectCondition::forIpAddress('192.168.1.0/24'),
'https://example.com/from-rule',
];
yield 'matching wildcard IP address' => [
$request()->withAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR, '1.2.5.5'),
RedirectCondition::forIpAddress('1.2.*.*'),
'https://example.com/from-rule',
];
yield 'non-matching IP address' => [
$request()->withAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR, '4.3.2.1'),
RedirectCondition::forIpAddress('1.2.3.4'),
'https://example.com/foo/bar',
];
yield 'missing remote IP address' => [
$request(),
RedirectCondition::forIpAddress('1.2.3.4'),
'https://example.com/foo/bar',
];
}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Util;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Util\IpAddressUtils;
class IpAddressUtilsTest extends TestCase
{
#[Test]
#[TestWith(['', false], 'empty')]
#[TestWith(['invalid', false], 'invalid')]
#[TestWith(['1.2.3.4', true], 'static IP')]
#[TestWith(['456.2.385.4', false], 'invalid IP')]
#[TestWith(['192.168.1.0/24', true], 'CIDR block')]
#[TestWith(['1.2.*.*', true], 'wildcard pattern')]
#[TestWith(['1.2.*.1', true], 'in-between wildcard pattern')]
public function isStaticIpCidrOrWildcardReturnsExpectedResult(string $candidate, bool $expected): void
{
self::assertEquals($expected, IpAddressUtils::isStaticIpCidrOrWildcard($candidate));
}
}

View File

@ -92,6 +92,21 @@ class RequestTrackerTest extends TestCase
$this->requestTracker->trackIfApplicable($shortUrl, $this->request);
}
#[Test]
public function trackingHappensOverShortUrlsWhenRemoteAddressIsInvalid(): void
{
$shortUrl = ShortUrl::withLongUrl(self::LONG_URL);
$this->visitsTracker->expects($this->once())->method('track')->with(
$shortUrl,
$this->isInstanceOf(Visitor::class),
);
$this->requestTracker->trackIfApplicable($shortUrl, ServerRequestFactory::fromGlobals()->withAttribute(
IpAddressMiddlewareFactory::REQUEST_ATTR,
'invalid',
));
}
#[Test]
public function baseUrlErrorIsTracked(): void
{

View File

@ -87,6 +87,17 @@ class ListRedirectRulesTest extends ApiTestCase
],
],
],
[
'longUrl' => 'https://example.com/static-ip-address',
'priority' => 6,
'conditions' => [
[
'type' => 'ip-address',
'matchKey' => null,
'matchValue' => '1.2.3.4',
],
],
],
]])]
public function returnsListOfRulesForShortUrl(string $shortCode, array $expectedRules): void
{

View File

@ -25,7 +25,7 @@ class SetRedirectRulesTest extends ApiTestCase
];
#[Test]
public function errorIsReturnedWhenInvalidUrlProvided(): void
public function errorIsReturnedWhenInvalidUrlIsProvided(): void
{
$response = $this->callApiWithKey(self::METHOD_POST, '/short-urls/invalid/redirect-rules');
$payload = $this->getJsonResponsePayload($response);
@ -39,16 +39,67 @@ class SetRedirectRulesTest extends ApiTestCase
}
#[Test]
public function errorIsReturnedWhenInvalidDataProvided(): void
{
$response = $this->callApiWithKey(self::METHOD_POST, '/short-urls/abc123/redirect-rules', [
RequestOptions::JSON => [
'redirectRules' => [
#[TestWith([[
'redirectRules' => [
[
'longUrl' => 'invalid',
],
],
]], 'invalid long URL')]
#[TestWith([[
'redirectRules' => [
[
'longUrl' => 'https://example.com',
'conditions' => 'foo',
],
],
]], 'non-array conditions')]
#[TestWith([[
'redirectRules' => [
[
'longUrl' => 'https://example.com',
'conditions' => [
[
'longUrl' => 'invalid',
'type' => 'invalid',
'matchKey' => null,
'matchValue' => 'foo',
],
],
],
],
]], 'invalid condition type')]
#[TestWith([[
'redirectRules' => [
[
'longUrl' => 'https://example.com',
'conditions' => [
[
'type' => 'device',
'matchValue' => 'invalid-device',
'matchKey' => null,
],
],
],
],
]], 'invalid device type')]
#[TestWith([[
'redirectRules' => [
[
'longUrl' => 'https://example.com',
'conditions' => [
[
'type' => 'ip-address',
'matchKey' => null,
'matchValue' => 'not an IP address',
],
],
],
],
]], 'invalid IP address')]
public function errorIsReturnedWhenInvalidDataIsProvided(array $bodyPayload): void
{
$response = $this->callApiWithKey(self::METHOD_POST, '/short-urls/abc123/redirect-rules', [
RequestOptions::JSON => $bodyPayload,
]);
$payload = $this->getJsonResponsePayload($response);

View File

@ -70,6 +70,14 @@ class ShortUrlRedirectRulesFixture extends AbstractFixture implements DependentF
);
$manager->persist($iosRule);
$ipAddressRule = new ShortUrlRedirectRule(
shortUrl: $defShortUrl,
priority: 6,
longUrl: 'https://example.com/static-ip-address',
conditions: new ArrayCollection([RedirectCondition::forIpAddress('1.2.3.4')]),
);
$manager->persist($ipAddressRule);
$manager->flush();
}
}