@@ -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 (){
0 commit comments