From 42e9aaed354fa7f3426c487dad4b1190ad745688 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 13 Jul 2026 10:02:13 +0200 Subject: [PATCH 1/2] URl validator; support private schemas --- src/Validator/URL.php | 56 +++++++++++++++++++++++++++++++++++-- tests/Validator/URLTest.php | 48 +++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/src/Validator/URL.php b/src/Validator/URL.php index 6be43de..9ee8fd8 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,54 @@ 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); + + // Scheme must match the RFC 3986 grammar (parse_url does not enforce this). + if (\preg_match('/^[a-zA-Z][a-zA-Z0-9+.\-]*$/', $scheme) !== 1) { + return false; + } + + // Must be a reverse-DNS / private-use scheme (contains at least one dot), + // per RFC 8252 §7.1. This also excludes standard dotless schemes. + if (!\str_contains($scheme, '.')) { + return false; + } + + // Reject the "scheme://…" authority form; that is handled by filter_var. + if (\str_starts_with($remainder, '//')) { + return false; + } + + // The remainder must be a valid RFC 3986 path (with no authority), + // optionally followed by a query and/or fragment component. + $pchar = "A-Za-z0-9\\-._~!\$&'()*+,;=:@"; + $pct = '%[0-9A-Fa-f]{2}'; + $path = "(?:[$pchar/]|$pct)*"; + $queryOrFragment = "(?:[$pchar/?]|$pct)*"; + + return \preg_match("#^$path(?:\\?$queryOrFragment)?(?:\\#$queryOrFragment)?$#", $remainder) === 1; + } + /** * 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')); + } } From d89a284f7882baf1cbb4a81ae8c465e37a392ba1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 13 Jul 2026 10:04:54 +0200 Subject: [PATCH 2/2] Remove regex --- src/Validator/URL.php | 49 ++++++++++++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/src/Validator/URL.php b/src/Validator/URL.php index 9ee8fd8..e14ba5a 100644 --- a/src/Validator/URL.php +++ b/src/Validator/URL.php @@ -93,30 +93,51 @@ private function isPrivateUseSchemeURI($value): bool $scheme = \substr($value, 0, $colonPos); $remainder = \substr($value, $colonPos + 1); - // Scheme must match the RFC 3986 grammar (parse_url does not enforce this). - if (\preg_match('/^[a-zA-Z][a-zA-Z0-9+.\-]*$/', $scheme) !== 1) { + if (!$this->isPrivateUseScheme($scheme)) { return false; } - // Must be a reverse-DNS / private-use scheme (contains at least one dot), - // per RFC 8252 §7.1. This also excludes standard dotless schemes. - if (!\str_contains($scheme, '.')) { + // Reject the "scheme://…" authority form; that is handled by filter_var. + if (\str_starts_with($remainder, '//')) { return false; } - // Reject the "scheme://…" authority form; that is handled by filter_var. - if (\str_starts_with($remainder, '//')) { + // 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; } - // The remainder must be a valid RFC 3986 path (with no authority), - // optionally followed by a query and/or fragment component. - $pchar = "A-Za-z0-9\\-._~!\$&'()*+,;=:@"; - $pct = '%[0-9A-Fa-f]{2}'; - $path = "(?:[$pchar/]|$pct)*"; - $queryOrFragment = "(?:[$pchar/?]|$pct)*"; + foreach (\str_split($scheme) as $char) { + if (!\ctype_alnum($char) && !\in_array($char, ['+', '-', '.'], true)) { + return false; + } + } - return \preg_match("#^$path(?:\\?$queryOrFragment)?(?:\\#$queryOrFragment)?$#", $remainder) === 1; + return true; } /**