diff --git a/src/Validator/URL.php b/src/Validator/URL.php index 6be43de..e14ba5a 100644 --- a/src/Validator/URL.php +++ b/src/Validator/URL.php @@ -13,7 +13,7 @@ */ class URL extends Validator { - public function __construct(protected array $allowedSchemes = [], protected bool $allowEmpty = false, protected bool $allowFragments = true) {} + public function __construct(protected array $allowedSchemes = [], protected bool $allowEmpty = false, protected bool $allowFragments = true, protected bool $allowPrivateUseSchemes = false) {} /** * Get Description @@ -53,7 +53,11 @@ public function isValid($value): bool } if (filter_var($value, FILTER_VALIDATE_URL) === false) { - return false; + // FILTER_VALIDATE_URL rejects authority-less private-use URI schemes + // (e.g. "com.example.app:/oauth", RFC 8252 §7.1). Optionally accept those. + if (!($this->allowPrivateUseSchemes && $this->isPrivateUseSchemeURI($value))) { + return false; + } } if ($this->allowedSchemes !== [] && !\in_array(parse_url($value, PHP_URL_SCHEME), $this->allowedSchemes)) { @@ -67,6 +71,75 @@ public function isValid($value): bool return true; } + /** + * Is private-use URI scheme + * + * Returns true when $value is an authority-less private-use URI scheme + * redirect URI as defined by RFC 8252 §7.1, e.g. "com.example.app:/oauth". + * + * @param mixed $value + */ + private function isPrivateUseSchemeURI($value): bool + { + if (!\is_string($value)) { + return false; + } + + $colonPos = \strpos($value, ':'); + if ($colonPos === false) { + return false; + } + + $scheme = \substr($value, 0, $colonPos); + $remainder = \substr($value, $colonPos + 1); + + if (!$this->isPrivateUseScheme($scheme)) { + return false; + } + + // Reject the "scheme://…" authority form; that is handled by filter_var. + if (\str_starts_with($remainder, '//')) { + return false; + } + + // Validate the authority-less remainder (a path, optionally with a query + // and/or fragment) by reusing PHP's URL validation with a placeholder + // authority, since filter_var alone rejects the authority-less form. + $normalized = \str_starts_with($remainder, '/') + ? 'https://localhost' . $remainder + : 'https://localhost/' . $remainder; + + return filter_var($normalized, FILTER_VALIDATE_URL) !== false; + } + + /** + * Is private-use scheme + * + * Returns true when $scheme is a valid RFC 3986 scheme that is also a + * reverse-DNS / private-use scheme (contains a dot), per RFC 8252 §7.1. + * The dot requirement also excludes standard dotless schemes (http, ftp, …). + * parse_url does not enforce the scheme grammar, so validate it explicitly. + */ + private function isPrivateUseScheme(string $scheme): bool + { + // RFC 3986: scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) + if ($scheme === '' || !\ctype_alpha($scheme[0])) { + return false; + } + + if (!\str_contains($scheme, '.')) { + return false; + } + + foreach (\str_split($scheme) as $char) { + if (!\ctype_alnum($char) && !\in_array($char, ['+', '-', '.'], true)) { + return false; + } + } + + return true; + } + /** * Is array * diff --git a/tests/Validator/URLTest.php b/tests/Validator/URLTest.php index c76d8be..43038b6 100644 --- a/tests/Validator/URLTest.php +++ b/tests/Validator/URLTest.php @@ -87,4 +87,52 @@ public function testDisallowFragmentsAllowedSchemes(): void $this->assertFalse($urlWithoutFragments->isValid('https://example.com/callback#fragment')); $this->assertFalse($urlWithoutFragments->isValid('gopher://www.example.com')); } + + public function testAllowPrivateUseSchemes(): void + { + // Default: private-use schemes are rejected (backward compatibility). + $default = new URL(); + $this->assertFalse($default->isValid('com.raycast-x:/oauth')); + $this->assertFalse($default->isValid('com.example.app:/oauth2redirect/example-provider')); + + $url = new URL(allowPrivateUseSchemes: true); + + // Happy path — RFC 8252 §7.1 private-use URI scheme redirect URIs. + $this->assertTrue($url->isValid('com.raycast-x:/oauth')); + $this->assertTrue($url->isValid('com.example.app:/oauth2redirect/example-provider')); + $this->assertTrue($url->isValid('com.raycast-x:/oauth?state=abc')); // query allowed + $this->assertTrue($url->isValid('com.raycast-x:oauth')); // path-rootless (opaque) form + + // Standard hierarchical URLs still validate through the normal path. + $this->assertTrue($url->isValid('https://example.com/callback')); + $this->assertTrue($url->isValid('http://127.0.0.1:8080/callback')); // loopback redirect + + // Edge-case failures. + $this->assertFalse($url->isValid('http:/example.com')); // dotless standard scheme, no authority — still invalid + $this->assertFalse($url->isValid('1com.raycast:/oauth')); // scheme must not start with a digit (RFC 3986) + $this->assertFalse($url->isValid('com raycast:/oauth')); // space in scheme + $this->assertFalse($url->isValid(':/oauth')); // missing scheme + $this->assertFalse($url->isValid('/oauth')); // no scheme at all + $this->assertFalse($url->isValid('comraycast:/oauth')); // no dot -> not treated as reverse-DNS private-use scheme + $this->assertFalse($url->isValid('not a url')); + $this->assertFalse($url->isValid('')); // allowEmpty not set + } + + public function testAllowPrivateUseSchemesWithConstraints(): void + { + // Fragments forbidden must also apply to private-use schemes (RFC 6749 §3.1.2). + $noFragments = new URL(allowFragments: false, allowPrivateUseSchemes: true); + $this->assertTrue($noFragments->isValid('com.raycast-x:/oauth')); + $this->assertFalse($noFragments->isValid('com.raycast-x:/oauth#frag')); + + // allowEmpty composes as before. + $allowEmpty = new URL(allowEmpty: true, allowPrivateUseSchemes: true); + $this->assertTrue($allowEmpty->isValid('')); + $this->assertTrue($allowEmpty->isValid('com.raycast-x:/oauth')); + + // allowedSchemes still gates private-use schemes. + $scoped = new URL(['com.raycast-x'], allowPrivateUseSchemes: true); + $this->assertTrue($scoped->isValid('com.raycast-x:/oauth')); + $this->assertFalse($scoped->isValid('com.evil-app:/oauth')); + } }