Skip to content

Commit 37c73d0

Browse files
committed
refactor(oauth2): consolidate runtime URI matching into a single pipeline
The three runtime allow-gates (isUriAllowed, isPostLogoutUriAllowed, isOriginAllowed) each carried a hand-rolled copy of the same algorithm - explode the registered CSV, trim, canonicalize+normalize each side, exact compare - which is how the RFC 8252 SS7.3 loopback port rule ended up patched into one copy and silently absent from its siblings. Knowledge now lives in one place each: - URLUtils::canonicalizeForMatch(): the canonicalUrl->normalizeUrl->null-guard sequence, previously repeated 6+ times. - URLUtils::anyCanonicalMatchesList(): the ONLY registered-list matching loop. Per-field differences are caller arguments, not separate algorithms: the redirect gate passes use_port from the loopback rule, post-logout passes true (port matched exactly - deliberate, see ADR decision 3), origin passes its two canonical forms (without-port matches any requested port when no port is registered; with-port requires the exact one). - Client::isRfc8252LoopbackRedirect(): the named RFC 8252 SS7.3 predicate, extracted from an inline $use_port expression. - AbstractIndirectResponseStrategy::redirectTo(): the verbatim-Location emitter guard, previously duplicated across the query-string and fragment strategies. - Swapped URLUtils' dead 'use AWS\CRT\Log' import for the Log facade the new matcher logs through. Zero behavior change; the existing suite is the net: Application 196 tests / 1004 assertions, OTEL 23+12, all 0 failures - identical counts to pre-refactor. Live re-verified at /oauth2/end-session: authority-less registered URI -> 302 verbatim Location, https registered -> 302, unregistered -> 400. ADR decision 3 now declares the post-logout port asymmetry intentional and points at the single flag that would change it. Note for local dev environments: the new AbstractIndirectResponseStrategy requires a composer dump-autoload (classmap); CI regenerates it on install.
1 parent dbafdb7 commit 37c73d0

6 files changed

Lines changed: 182 additions & 125 deletions

File tree

app/Models/OAuth2/Client.php

Lines changed: 82 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,26 @@ private function isNativeDangerousScheme(string $scheme, ?string $host = null):
658658
return $this->application_type === IClient::ApplicationType_Native && self::isDisallowedNativeUriScheme($scheme, $host);
659659
}
660660

661+
/**
662+
* RFC 8252 SS7.3: a Native client's http-loopback redirect binds an EPHEMERAL port at request
663+
* time - "the authorization server MUST allow any port to be specified at the time of the
664+
* request for loopback IP redirect URIs". Single place this rule is decided; isUriAllowed()
665+
* feeds it into the matching pipeline as "ignore the port on both sides". Deliberately NOT
666+
* consulted by isPostLogoutUriAllowed() - no spec extends the carve-out to RP-initiated
667+
* logout (see ADR-0001, decision 3).
668+
*
669+
* @param array|false $parts result of parse_url() on the requested URI
670+
* @return bool
671+
*/
672+
private function isRfc8252LoopbackRedirect($parts): bool
673+
{
674+
return $this->application_type === IClient::ApplicationType_Native
675+
&& $parts !== false
676+
&& isset($parts['scheme'], $parts['host'])
677+
&& strtolower($parts['scheme']) === 'http'
678+
&& in_array(strtolower($parts['host']), IClient::NATIVE_LOOPBACK_HOSTS);
679+
}
680+
661681
/**
662682
* @param string $uri
663683
* @return bool
@@ -672,52 +692,42 @@ public function isUriAllowed(string $uri):bool
672692
return false;
673693
}
674694

675-
// RFC 8252 SS7.3: native apps doing http loopback redirection bind an EPHEMERAL port at
676-
// request time - "the authorization server MUST allow any port to be specified at the time
677-
// of the request for loopback IP redirect URIs". Only the port is ignored: scheme, host and
678-
// path still require an exact match, and the loopback hosts are not cross-matched.
679-
$use_port = !($this->application_type === IClient::ApplicationType_Native
680-
&& $original_parts !== false
681-
&& isset($original_parts['scheme'], $original_parts['host'])
682-
&& strtolower($original_parts['scheme']) === 'http'
683-
&& in_array(strtolower($original_parts['host']), IClient::NATIVE_LOOPBACK_HOSTS));
684-
685-
$uri = URLUtils::canonicalUrl($uri, $use_port);
686-
if(empty($uri)) {
695+
// RFC 8252 SS7.3 loopback redirects are compared port-agnostically - only the port is
696+
// ignored: scheme, host and path still require an exact match, and the loopback hosts are
697+
// not cross-matched (see isRfc8252LoopbackRedirect).
698+
$use_port = !$this->isRfc8252LoopbackRedirect($original_parts);
699+
700+
$canonical_uri = URLUtils::canonicalUrl($uri, $use_port);
701+
if(empty($canonical_uri)) {
687702
Log::debug(sprintf("Client::isUriAllowed url %s is not valid", $uri));
688703
return false;
689704
}
705+
// evaluated on the canonical (pre-normalization) form: normalizeUrl() lowercases the scheme,
706+
// and this check has always been case-sensitive on it.
690707
if
691708
(
692-
($this->application_type !== IClient::ApplicationType_Native && !URLUtils::isHTTPS($uri))
709+
($this->application_type !== IClient::ApplicationType_Native && !URLUtils::isHTTPS($canonical_uri))
693710
&& (ServerConfigurationService::getConfigValue("SSL.Enable"))
694711
)
695712
{
696-
Log::debug(sprintf("Client::isUriAllowed url %s is not under ssl schema", $uri));
713+
Log::debug(sprintf("Client::isUriAllowed url %s is not under ssl schema", $canonical_uri));
697714
return false;
698715
}
699716

700-
$redirect_uris = explode(',', $this->redirect_uris);
701-
$uri = URLUtils::normalizeUrl($uri);
702-
if(empty($uri)) return false;
703-
foreach($redirect_uris as $redirect_uri){
704-
$redirect_uri = trim($redirect_uri);
705-
if(empty($redirect_uri)) continue;
706-
707-
// symmetric normalization: compare both sides through the same canonicalize+normalize
708-
// pipeline, then require an exact match - a registered value must no longer be accepted
709-
// merely as a *prefix* of the requested URI (e.g. "myapp://callback" matching any
710-
// "myapp://callback/<anything>").
711-
$canonical_redirect_uri = URLUtils::canonicalUrl($redirect_uri, $use_port);
712-
if(empty($canonical_redirect_uri)) continue;
713-
$canonical_redirect_uri = URLUtils::normalizeUrl($canonical_redirect_uri);
714-
715-
Log::debug(sprintf("Client::isUriAllowed url %s client %s redirect_uri %s", $uri, $this->client_id, $canonical_redirect_uri));
716-
if($uri === $canonical_redirect_uri)
717-
return true;
718-
}
717+
$requested_uri = URLUtils::normalizeUrl($canonical_uri);
718+
if(empty($requested_uri)) return false;
719719

720-
Log::debug(sprintf("Client::isUriAllowed url %s is not allowed as return url for client %s", $uri, $this->client_id));
720+
// exact match against each registered value, both sides through the same canonicalize+normalize
721+
// pipeline (URLUtils::anyCanonicalMatchesList) - a registered value must not be accepted merely
722+
// as a *prefix* of the requested URI (e.g. "myapp://callback" matching "myapp://callback/<x>").
723+
if(URLUtils::anyCanonicalMatchesList(
724+
[$requested_uri],
725+
$this->redirect_uris,
726+
$use_port,
727+
sprintf("Client::isUriAllowed client %s", $this->client_id)))
728+
return true;
729+
730+
Log::debug(sprintf("Client::isUriAllowed url %s is not allowed as return url for client %s", $requested_uri, $this->client_id));
721731
return false;
722732
}
723733

@@ -863,37 +873,27 @@ public function getRawClientAllowedOrigins()
863873
*/
864874
public function isOriginAllowed(string $origin):bool
865875
{
866-
$originWithoutPort = URLUtils::canonicalUrl($origin, false);
867-
if(empty($originWithoutPort)) return false;
868-
$originWithoutPort = URLUtils::normalizeUrl($originWithoutPort);
869-
// defensive: no reproducible input reaches this with a null (canonicalUrl()'s
870-
// filter_var/parse_url guard rejects everything malformed first), but the underlying
871-
// Normalizer's mbParseUrl() can diverge from parse_url() and reset to an empty state -
872-
// a null here comparing against a null registered-side normalization would false-match.
873-
if(empty($originWithoutPort)) return false;
874-
875-
$originWithPort = URLUtils::canonicalUrl($origin);
876-
$originWithPort = empty($originWithPort) ? null : URLUtils::normalizeUrl($originWithPort);
877-
878-
// exact match against each registered value, through the same canonicalize+normalize pipeline on
879-
// both sides (mirrors isUriAllowed()/isPostLogoutUriAllowed()) - a registered origin must no longer
880-
// match merely because the requested origin is a string prefix of it (e.g. registered
881-
// "https://my-app.example.com" incorrectly matching a requested "https://my-app.example.co" under
882-
// the old str_contains($this->allowed_origins, $origin) check).
883-
foreach(explode(',', $this->allowed_origins) as $allowed_origin){
884-
$allowed_origin = trim($allowed_origin);
885-
if(empty($allowed_origin)) continue;
886-
887-
$canonical_allowed_origin = URLUtils::canonicalUrl($allowed_origin);
888-
if(empty($canonical_allowed_origin)) continue;
889-
$canonical_allowed_origin = URLUtils::normalizeUrl($canonical_allowed_origin);
890-
if(empty($canonical_allowed_origin)) continue;
891-
892-
if($originWithoutPort === $canonical_allowed_origin) return true;
893-
if($originWithPort !== null && $originWithPort === $canonical_allowed_origin) return true;
894-
}
876+
// exact match against each registered value, both sides through the same canonicalize+normalize
877+
// pipeline (URLUtils::anyCanonicalMatchesList) - a registered origin must not match merely
878+
// because the requested origin is a string prefix of it. The requested origin is offered in
879+
// TWO canonical forms: without its port (so a registered origin with no explicit port matches
880+
// the request on any port) and with it (so a registered origin WITH a port only matches the
881+
// request carrying that exact port). canonicalizeForMatch() yielding null on either side can
882+
// never false-match - a null requested form is dropped, a null registered item is skipped.
883+
$requested_origins = [];
895884

896-
return false;
885+
$originWithoutPort = URLUtils::canonicalizeForMatch($origin, false);
886+
if(is_null($originWithoutPort)) return false;
887+
$requested_origins[] = $originWithoutPort;
888+
889+
$originWithPort = URLUtils::canonicalizeForMatch($origin);
890+
if(!is_null($originWithPort)) $requested_origins[] = $originWithPort;
891+
892+
return URLUtils::anyCanonicalMatchesList(
893+
$requested_origins,
894+
$this->allowed_origins,
895+
true,
896+
sprintf("Client::isOriginAllowed client %s", $this->client_id));
897897
}
898898

899899
public function getWebsite()
@@ -1190,33 +1190,24 @@ public function isPostLogoutUriAllowed($post_logout_uri)
11901190
if($this->isNativeDangerousScheme($parts['scheme'], $parts['host'] ?? null))
11911191
return false;
11921192

1193-
// NOTE: no isset($parts['host']) guard here - authority-less URIs go through canonicalUrl(),
1194-
// which either canonicalizes them (RFC 8252 SS7.1 rooted-path form) or returns null (opaque
1195-
// forms like mailto:foo@bar), so the host-less crash this gate used to have cannot recur.
1196-
1197-
// exact match against each registered value, through the same canonicalize+normalize pipeline on
1198-
// both sides (mirrors isUriAllowed()): a registered value's scheme+host[:port] must no longer match
1199-
// as a prefix of an unrelated path - the full path is now part of the comparison, and scheme/host
1200-
// are still matched case-insensitively since canonicalUrl()+normalizeUrl() lowercase both. Query
1201-
// strings remain tolerated - canonicalUrl() drops them from both sides, so a client's dynamic
1202-
// ?state=.../?session=... params never break the match.
1203-
$canonical_uri = URLUtils::canonicalUrl($post_logout_uri);
1204-
if(empty($canonical_uri)) return false;
1205-
$canonical_uri = URLUtils::normalizeUrl($canonical_uri);
1206-
if(empty($canonical_uri)) return false;
1207-
1208-
foreach(explode(',', $this->post_logout_redirect_uris) as $registered_uri){
1209-
$registered_uri = trim($registered_uri);
1210-
if(empty($registered_uri)) continue;
1211-
1212-
$canonical_registered_uri = URLUtils::canonicalUrl($registered_uri);
1213-
if(empty($canonical_registered_uri)) continue;
1214-
$canonical_registered_uri = URLUtils::normalizeUrl($canonical_registered_uri);
1215-
1216-
if($canonical_uri === $canonical_registered_uri) return true;
1217-
}
1218-
1219-
return false;
1193+
// NOTE: no isset($parts['host']) guard here - authority-less URIs go through the matching
1194+
// pipeline, which either canonicalizes them (RFC 8252 SS7.1 rooted-path form) or yields null
1195+
// (opaque forms like mailto:foo@bar), so the host-less crash this gate used to have cannot recur.
1196+
1197+
// exact match against each registered value, both sides through the same canonicalize+normalize
1198+
// pipeline (URLUtils::anyCanonicalMatchesList): the full path is part of the comparison,
1199+
// scheme/host stay case-insensitive, and query strings remain tolerated (dropped from both
1200+
// sides), so a client's dynamic ?state=.../?session=... params never break the match. The
1201+
// registered port is matched exactly - the RFC 8252 SS7.3 port carve-out deliberately applies
1202+
// to isUriAllowed() only (see isRfc8252LoopbackRedirect / ADR-0001 decision 3).
1203+
$requested_uri = URLUtils::canonicalizeForMatch($post_logout_uri);
1204+
if(is_null($requested_uri)) return false;
1205+
1206+
return URLUtils::anyCanonicalMatchesList(
1207+
[$requested_uri],
1208+
$this->post_logout_redirect_uris,
1209+
true,
1210+
sprintf("Client::isPostLogoutUriAllowed client %s", $this->client_id));
12201211
}
12211212

12221213
public function getAdminUsers(){
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php namespace Strategies;
2+
/**
3+
* Copyright 2026 OpenStack Foundation
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
**/
14+
use Illuminate\Http\RedirectResponse;
15+
use Illuminate\Support\Facades\Redirect;
16+
use Illuminate\Support\Facades\URL;
17+
use Utils\IHttpResponseStrategy;
18+
/**
19+
* Class AbstractIndirectResponseStrategy
20+
* Shared 302 emitter for the indirect (redirect-carrying) response strategies.
21+
* @package Strategies
22+
*/
23+
abstract class AbstractIndirectResponseStrategy implements IHttpResponseStrategy
24+
{
25+
/**
26+
* RFC 8252 SS7.1 authority-less URIs (com.example.app:/cb?code=...) fail Laravel's
27+
* UrlGenerator::isValidUrl(), so Redirect::to() would treat the already-validated redirect
28+
* target as a RELATIVE path and prefix the site URL, corrupting the redirect. For an absolute
29+
* URI (leading scheme) Laravel does not recognize, emit the Location verbatim - Symfony still
30+
* rejects CR/LF in header values, so no header-injection surface is opened.
31+
*
32+
* @param string $return_to the already-validated redirect target, params appended
33+
* @return RedirectResponse
34+
*/
35+
protected function redirectTo(string $return_to)
36+
{
37+
$redirect = (!URL::isValidUrl($return_to) && preg_match('~^[A-Za-z][A-Za-z0-9+.\-]*:~', $return_to) === 1)
38+
? new RedirectResponse($return_to)
39+
: Redirect::to($return_to);
40+
41+
return $redirect
42+
->header('Cache-Control', 'no-cache, no-store, max-age=0, must-revalidate')
43+
->header('Pragma','no-cache');
44+
}
45+
}

app/Strategies/IndirectResponseQueryStringStrategy.php

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,13 @@
1111
* See the License for the specific language governing permissions and
1212
* limitations under the License.
1313
**/
14-
use Utils\IHttpResponseStrategy;
15-
use Illuminate\Http\RedirectResponse;
16-
use Illuminate\Support\Facades\Redirect;
1714
use Illuminate\Support\Facades\Response;
18-
use Illuminate\Support\Facades\URL;
1915
/**
2016
* Class IndirectResponseQueryStringStrategy
2117
* Redirect and http response using a 302 adding params on query string
2218
* @package Strategies
2319
*/
24-
class IndirectResponseQueryStringStrategy implements IHttpResponseStrategy
20+
class IndirectResponseQueryStringStrategy extends AbstractIndirectResponseStrategy
2521
{
2622

2723
/**
@@ -38,17 +34,6 @@ public function handle($response)
3834
}
3935
$return_to = (strpos($return_to, "?") == false) ? $return_to . "?" . $query_string : $return_to . "&" . $query_string;
4036

41-
// RFC 8252 SS7.1 authority-less URIs (com.example.app:/cb?code=...) fail Laravel's
42-
// UrlGenerator::isValidUrl(), so Redirect::to() would treat the already-validated redirect
43-
// target as a RELATIVE path and prefix the site URL, corrupting the redirect. For an absolute
44-
// URI (leading scheme) Laravel does not recognize, emit the Location verbatim - Symfony still
45-
// rejects CR/LF in the header value, so no header-injection surface is opened.
46-
$redirect = (!URL::isValidUrl($return_to) && preg_match('~^[A-Za-z][A-Za-z0-9+.\-]*:~', $return_to) === 1)
47-
? new RedirectResponse($return_to)
48-
: Redirect::to($return_to);
49-
50-
return $redirect
51-
->header('Cache-Control', 'no-cache, no-store, max-age=0, must-revalidate')
52-
->header('Pragma','no-cache');
37+
return $this->redirectTo($return_to);
5338
}
5439
}

app/Strategies/IndirectResponseUrlFragmentStrategy.php

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,13 @@
1111
* See the License for the specific language governing permissions and
1212
* limitations under the License.
1313
**/
14-
use Utils\IHttpResponseStrategy;
15-
use Illuminate\Http\RedirectResponse;
16-
use Illuminate\Support\Facades\Redirect;
1714
use Illuminate\Support\Facades\Response;
18-
use Illuminate\Support\Facades\URL;
1915
/**
2016
* Class IndirectResponseUrlFragmentStrategy
2117
* Redirect and http response using a 302 adding params on url fragment
2218
* @package Strategies
2319
*/
24-
class IndirectResponseUrlFragmentStrategy implements IHttpResponseStrategy
20+
class IndirectResponseUrlFragmentStrategy extends AbstractIndirectResponseStrategy
2521
{
2622

2723
/**
@@ -39,15 +35,6 @@ public function handle($response)
3935

4036
$return_to = (strpos($return_to, "#") == false) ? $return_to . "#" . $fragment : $return_to . "&" . $fragment;
4137

42-
// same RFC 8252 SS7.1 authority-less guard as IndirectResponseQueryStringStrategy: an absolute
43-
// URI Laravel's UrlGenerator does not recognize must be emitted verbatim, or Redirect::to()
44-
// prefixes the site URL and corrupts the already-validated redirect target.
45-
$redirect = (!URL::isValidUrl($return_to) && preg_match('~^[A-Za-z][A-Za-z0-9+.\-]*:~', $return_to) === 1)
46-
? new RedirectResponse($return_to)
47-
: Redirect::to($return_to);
48-
49-
return $redirect
50-
->header('Cache-Control', 'no-cache, no-store, max-age=0, must-revalidate')
51-
->header('Pragma','no-cache');
38+
return $this->redirectTo($return_to);
5239
}
5340
}

0 commit comments

Comments
 (0)