From 5a2c20708c2fb6d794865cbff6e64d037b41f697 Mon Sep 17 00:00:00 2001 From: kenedytorcatt Date: Mon, 11 May 2026 19:04:25 -0600 Subject: [PATCH] fix(sso): guard against unpopulated $current_blog during early bootstrap Two defensive guards in inc/sso/class-sso.php that prevent SSO_Exception when convert_bearer_into_auth_cookies() runs before $current_blog is fully populated. Root cause ========== The callback is attached to `init`, but on some multisite bootstraps the $GLOBALS['current_blog'] object exists while its properties --- including `registered` --- are still empty. Reproduced reliably on: * Mapped domains right after a cold cache flush. * Admin pages loaded inside an iframe (custom dashboards, third-party "frontend admin" plugins) where the SSO bearer exchange races with WordPress finishing sunrise.php and ms-settings.php. * Hosts that warm object caches between sunrise and ms-settings. In those requests get_broker() ends up calling calculate_secret_from_date($current_blog->registered) with an empty string, which throws SSO_Exception. Because the exception is uncaught at that level the parent iframe response becomes a 500 / blank page and the admin UI never renders. Fix === 1. convert_bearer_into_auth_cookies(): bail out early when the global blog object is missing or its `registered` property is empty. The conversion is naturally retried on the next request once WordPress has finished populating $current_blog, so no SSO state is lost. 2. calculate_secret_from_date(): when $date is empty, fall back to the main site's registered date instead of throwing. Any caller that reaches this method through a path other than (1) (custom code that builds secrets from $current_blog->registered before sunrise.php finishes) gets a stable, consistent secret for the network rather than a 500. Both branches are pure early-return / fallback guards: when the data is present the behaviour is unchanged. Impact ====== Production deploy on a 300+ subsite multisite (kursopro.com) since 2026-05-07 with zero regressions. Resolves a recurring "blank admin iframe" bug on mapped-domain subsites that was masked by full-page cache on healthy requests but reappeared whenever the cache was invalidated. Tested on PHP 8.3, WordPress 6.9, multisite with sunrise.php enabled and domain mapping active. --- inc/sso/class-sso.php | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/inc/sso/class-sso.php b/inc/sso/class-sso.php index ca0fbd326..657faadf2 100644 --- a/inc/sso/class-sso.php +++ b/inc/sso/class-sso.php @@ -1063,6 +1063,26 @@ public function determine_current_user($current_user_id) { */ public function convert_bearer_into_auth_cookies(): void { + /* + * Bail out early when $current_blog has not been fully populated yet. + * + * This callback runs on `init`, but on some multisite bootstraps + * (mapped domains, iframe'd admin requests, hosts that warm caches + * before sunrise.php finishes) `$GLOBALS['current_blog']` is present + * as an object but its properties -- including `registered` -- are + * still empty. `get_broker()` then calls + * `calculate_secret_from_date($current_blog->registered)` with an + * empty string, which throws SSO_Exception and breaks any admin UI + * loaded through an iframe via SSO. + * + * Skipping this request is safe: the next request in the same + * session re-runs this callback with a fully populated + * `$current_blog` and completes the conversion. + */ + if (empty($GLOBALS['current_blog']) || empty($GLOBALS['current_blog']->registered)) { + return; + } + $broker = $this->get_broker(); if (is_user_logged_in() && $broker && $broker->isAttached()) { @@ -1336,6 +1356,27 @@ public function logger() { */ public function calculate_secret_from_date($date) { + /* + * Fall back to the main site's registration date when $date is + * empty. + * + * This guards against the same multisite bootstrap race that + * `convert_bearer_into_auth_cookies()` skips: a caller can still + * reach this method with an empty $date (for example, custom code + * that builds an SSO secret from `$current_blog->registered` before + * sunrise.php finishes populating it). Throwing SSO_Exception here + * breaks the parent request, which on iframe'd admin pages renders + * the whole panel blank. + * + * Using the main site's registration date as a fallback keeps the + * secret stable across requests for the same network and avoids + * cascading failures during early boot. + */ + if (empty($date)) { + $main_site = function_exists('get_site') ? get_site(function_exists('get_main_site_id') ? get_main_site_id() : 1) : null; + $date = ($main_site && ! empty($main_site->registered)) ? $main_site->registered : '2024-01-01 00:00:00'; + } + $tz = new \DateTimeZone('GMT'); try {