diff --git a/module/CLI/src/RedirectRule/RedirectRuleHandler.php b/module/CLI/src/RedirectRule/RedirectRuleHandler.php index 068cdc74..cb1d3faf 100644 --- a/module/CLI/src/RedirectRule/RedirectRuleHandler.php +++ b/module/CLI/src/RedirectRule/RedirectRuleHandler.php @@ -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?'); diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index 5900e8e1..720f27c1 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -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); +} diff --git a/module/Core/src/RedirectRule/Entity/RedirectCondition.php b/module/Core/src/RedirectRule/Entity/RedirectCondition.php index 3032eb1a..59c2798b 100644 --- a/module/Core/src/RedirectRule/Entity/RedirectCondition.php +++ b/module/Core/src/RedirectRule/Entity/RedirectCondition.php @@ -5,14 +5,14 @@ namespace Shlinkio\Shlink\Core\RedirectRule\Entity; use JsonSerializable; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Common\Entity\AbstractEntity; -use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory; 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; @@ -114,7 +114,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable private function matchesRemoteIpAddress(ServerRequestInterface $request): bool { - $remoteAddress = $request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR); + $remoteAddress = ipAddressFromRequest($request); return $remoteAddress !== null && IpAddressUtils::ipAddressMatchesGroups($remoteAddress, [$this->matchValue]); } diff --git a/module/Core/src/Util/IpAddressUtils.php b/module/Core/src/Util/IpAddressUtils.php index 6f8f566b..79e7f839 100644 --- a/module/Core/src/Util/IpAddressUtils.php +++ b/module/Core/src/Util/IpAddressUtils.php @@ -14,8 +14,9 @@ use function array_map; use function explode; use function implode; use function Shlinkio\Shlink\Core\ArrayUtils\some; +use function str_contains; -class IpAddressUtils +final class IpAddressUtils { /** * Checks if an IP address matches any of provided groups. diff --git a/module/Core/src/Visit/Model/Visitor.php b/module/Core/src/Visit/Model/Visitor.php index 8e3b7f0c..9cf524bc 100644 --- a/module/Core/src/Visit/Model/Visitor.php +++ b/module/Core/src/Visit/Model/Visitor.php @@ -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(), ); } diff --git a/module/Core/src/Visit/RequestTracker.php b/module/Core/src/Visit/RequestTracker.php index ecc3d94f..824e7c24 100644 --- a/module/Core/src/Visit/RequestTracker.php +++ b/module/Core/src/Visit/RequestTracker.php @@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Core\Visit; use Fig\Http\Message\RequestMethodInterface; 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; @@ -15,6 +14,8 @@ use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\Util\IpAddressUtils; use Shlinkio\Shlink\Core\Visit\Model\Visitor; +use function Shlinkio\Shlink\Core\ipAddressFromRequest; + readonly class RequestTracker implements RequestTrackerInterface, RequestMethodInterface { public function __construct(private VisitsTrackerInterface $visitsTracker, private TrackingOptions $trackingOptions) @@ -53,7 +54,7 @@ readonly class RequestTracker implements RequestTrackerInterface, RequestMethodI return false; } - $remoteAddr = $request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR); + $remoteAddr = ipAddressFromRequest($request); if ($this->shouldDisableTrackingFromAddress($remoteAddr)) { return false; } diff --git a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php index a8ab2a16..3cd44ef0 100644 --- a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php +++ b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php @@ -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); + } } diff --git a/module/Core/test/RedirectRule/Entity/ShortUrlRedirectRuleTest.php b/module/Core/test/RedirectRule/Entity/ShortUrlRedirectRuleTest.php index d61bc6fa..dc19dcd3 100644 --- a/module/Core/test/RedirectRule/Entity/ShortUrlRedirectRuleTest.php +++ b/module/Core/test/RedirectRule/Entity/ShortUrlRedirectRuleTest.php @@ -1,17 +1,20 @@ 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.*', ]]; } diff --git a/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php b/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php index 5bf435b2..3bf23863 100644 --- a/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php +++ b/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php @@ -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', + ]; } }