Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 75 additions & 2 deletions src/Validator/URL.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)) {
Expand All @@ -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
*
Expand Down
48 changes: 48 additions & 0 deletions tests/Validator/URLTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
}
}