Do not allow URL reserved characters in custom slugs

This commit is contained in:
Alejandro Celaya 2023-11-05 10:30:40 +01:00
parent d9d6d5bd9c
commit cfc3d54122
6 changed files with 155 additions and 7 deletions

View file

@ -77,7 +77,7 @@
"shlinkio/php-coding-standard": "~2.3.0",
"shlinkio/shlink-test-utils": "dev-main#cbbb64e as 3.8.0",
"symfony/var-dumper": "^6.3",
"veewee/composer-run-parallel": "^1.2"
"veewee/composer-run-parallel": "^1.3"
},
"autoload": {
"psr-4": {

View file

@ -10,8 +10,10 @@ use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
final class UrlShortenerOptions
{
/**
* @param array{schema: ?string, hostname: ?string} $domain
*/
public function __construct(
/** @var array{schema: ?string, hostname: ?string} */
public readonly array $domain = ['schema' => null, 'hostname' => null],
public readonly int $defaultShortCodesLength = DEFAULT_SHORT_CODES_LENGTH,
public readonly bool $autoResolveTitles = false,

View file

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Model\Validation;
use Laminas\Validator\AbstractValidator;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use function is_string;
use function strpbrk;
class CustomSlugValidator extends AbstractValidator
{
private const NOT_STRING = 'NOT_STRING';
private const CONTAINS_URL_CHARACTERS = 'CONTAINS_URL_CHARACTERS';
protected array $messageTemplates = [
self::NOT_STRING => 'Provided value is not a string.',
self::CONTAINS_URL_CHARACTERS => 'URL-reserved characters cannot be used in a custom slug.',
];
private UrlShortenerOptions $options;
private function __construct()
{
parent::__construct();
}
public static function forUrlShortenerOptions(UrlShortenerOptions $options): self
{
$instance = new self();
$instance->options = $options;
return $instance;
}
public function isValid(mixed $value): bool
{
if ($value === null) {
return true;
}
if (! is_string($value)) {
$this->error(self::NOT_STRING);
return false;
}
// URL reserved characters: https://datatracker.ietf.org/doc/html/rfc3986#section-2.2
$reservedChars = "!*'();:@&=+$,?%#[]";
if (! $this->options->multiSegmentSlugsEnabled) {
// Slashes should be allowed for multi-segment slugs
$reservedChars .= '/';
}
if (strpbrk($value, $reservedChars) !== false) {
$this->error(self::CONTAINS_URL_CHARACTERS);
return false;
}
return true;
}
}

View file

@ -81,13 +81,15 @@ class ShortUrlInputFilter extends InputFilter
$this->add($validUntil);
// The only way to enforce the NotEmpty validator to be evaluated when the key is present with an empty value
// is by using the deprecated setContinueIfEmpty
// is with setContinueIfEmpty
$customSlug = $this->createInput(self::CUSTOM_SLUG, false)->setContinueIfEmpty(true);
$customSlug->getFilterChain()->attach(new CustomSlugFilter($options));
$customSlug->getValidatorChain()->attach(new Validator\NotEmpty([
Validator\NotEmpty::STRING,
Validator\NotEmpty::SPACE,
]));
$customSlug->getValidatorChain()
->attach(new Validator\NotEmpty([
Validator\NotEmpty::STRING,
Validator\NotEmpty::SPACE,
]))
->attach(CustomSlugValidator::forUrlShortenerOptions($options));
$this->add($customSlug);
$this->add($this->createNumericInput(self::MAX_VISITS, false));

View file

@ -62,6 +62,10 @@ class ShortUrlCreationTest extends TestCase
ShortUrlInputFilter::LONG_URL => 'https://foo',
ShortUrlInputFilter::CUSTOM_SLUG => '',
]];
yield [[
ShortUrlInputFilter::LONG_URL => 'https://foo',
ShortUrlInputFilter::CUSTOM_SLUG => 'foo?some=param',
]];
yield [[
ShortUrlInputFilter::LONG_URL => 'https://foo',
ShortUrlInputFilter::CUSTOM_SLUG => ' ',

View file

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\ShortUrl\Model\Validation;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\CustomSlugValidator;
use stdClass;
class CustomSlugValidatorTest extends TestCase
{
#[Test]
public function nullIsValid(): void
{
$validator = $this->createValidator();
self::assertTrue($validator->isValid(null));
}
#[Test, DataProvider('provideNonStringValues')]
public function nonStringValuesAreInvalid(mixed $value): void
{
$validator = $this->createValidator();
self::assertFalse($validator->isValid($value));
self::assertEquals(['NOT_STRING' => 'Provided value is not a string.'], $validator->getMessages());
}
public static function provideNonStringValues(): iterable
{
yield [123];
yield [new stdClass()];
yield [true];
}
#[Test]
public function slashesAreAllowedWhenMultiSegmentSlugsAreEnabled(): void
{
$slugWithSlashes = 'foo/bar/baz';
self::assertTrue($this->createValidator(multiSegmentSlugsEnabled: true)->isValid($slugWithSlashes));
self::assertFalse($this->createValidator(multiSegmentSlugsEnabled: false)->isValid($slugWithSlashes));
}
#[Test, DataProvider('provideInvalidValues')]
public function valuesWithReservedCharsAreInvalid(string $value): void
{
$validator = $this->createValidator();
self::assertFalse($validator->isValid($value));
self::assertEquals(
['CONTAINS_URL_CHARACTERS' => 'URL-reserved characters cannot be used in a custom slug.'],
$validator->getMessages(),
);
}
public static function provideInvalidValues(): iterable
{
yield ['foo?bar=baz'];
yield ['some-thing#foo'];
yield ['call()'];
yield ['array[]'];
yield ['email@example.com'];
yield ['wildcard*'];
yield ['$500'];
}
public function createValidator(bool $multiSegmentSlugsEnabled = false): CustomSlugValidator
{
return CustomSlugValidator::forUrlShortenerOptions(
new UrlShortenerOptions(multiSegmentSlugsEnabled: $multiSegmentSlugsEnabled),
);
}
}