fix(sso): guard against unpopulated $current_blog during early bootstrap#1185
Conversation
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.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughSSO authentication adds defensive guards in two methods to prevent exceptions when blog data is incompletely populated during early multisite bootstrap phases (init hook, iframes, mapped domains). ChangesBootstrap State Guards
Estimated code review effort🎯 2 (Simple) | ⏱️ ~8 minutes Suggested labels
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
superdav42
left a comment
There was a problem hiding this comment.
Auto-approved by pulse runner @superdav42 — author @kenedytorcatt confirmed collaborator, pre-merge gates passed.
Reproduction + validation on stagingValidated this fix on a clean staging copy of our production multisite. Findings below. Setup
Reproduction with UM 2.11.0 official (no patch)Replaced UM on staging with the freshly-cut 2.11.0 release ZIP — vanilla, no modifications. Test script (run via WP-CLI $blog_id = 218;
switch_to_blog($blog_id);
// Simulate the bootstrap race: $current_blog object exists, but
// `registered` is still empty (matches what we observed in real
// requests right after sunrise.php finishes but before ms-settings.php
// finishes hydrating $current_blog).
$GLOBALS['current_blog']->registered = '';
try {
$sso = \WP_Ultimo\SSO\SSO::get_instance();
$secret = $sso->calculate_secret_from_date('');
echo 'OK: ' . $secret;
} catch (\Throwable $e) {
echo 'EXCEPTION: ' . get_class($e) . ' :: ' . $e->getMessage();
}Result on UM 2.11.0 official: In a real iframe'd admin request this becomes a 500 response and the WPFA / frontend-admin panel renders blank for the end user. Result with this PR appliedPatched only No exception. The fallback path returns a deterministic, network-scoped secret as designed. Side effects checked
RecommendationThis is what 2.11.0 needs to be drop-in safe for networks that use SSO + iframe'd admin (WPFA, frontend admin plugins, custom dashboards on mapped domains). Without it, anyone updating 2.10.1 → 2.11.0 will hit blank admin panels on cache miss for mapped-domain subsites — the same symptom that drove us to write this patch back in 2026-05-07. Happy to add unit tests for |
Full integration brief for the 2.11.x releaseDetailed breakdown so this can ship as part of the next 2.11.x without follow-up. The patch touches a single file ( Patch 1 —
|
| Scenario | Behavior with patch |
|---|---|
Healthy request, $current_blog->registered populated |
Unchanged — guards do not fire |
$GLOBALS['current_blog'] is null |
Returns early in convert_bearer_into_auth_cookies() |
$GLOBALS['current_blog'] is object, registered = '' |
Returns early; if calculate_secret_from_date() is invoked directly with '', falls back to main site date |
$GLOBALS['current_blog'] is object, registered = null |
Same as above (empty() covers both) |
Main site has empty registered (broken install) |
Falls back to literal '2024-01-01 00:00:00' instead of throwing |
| Subsite is suspended / deleted | No change — the patches do not interact with site status |
| User is not logged in | No change — the inner is_user_logged_in() && $broker && $broker->isAttached() block is reached only after the guard, same as before |
Suggested unit tests
// tests/SSO/SSOTest.php
public function test_calculate_secret_from_date_returns_main_site_fallback_for_empty_date(): void {
$sso = SSO::get_instance();
$main = get_site(get_main_site_id());
$expected = wp_hash((int) (new \DateTime($main->registered, new \DateTimeZone('GMT')))->format('mdisY'));
$actual = $sso->calculate_secret_from_date('');
$this->assertSame($expected, $actual, 'Empty date should fall back to the main site registered date.');
}
public function test_calculate_secret_from_date_unchanged_for_valid_date(): void {
$sso = SSO::get_instance();
$a = $sso->calculate_secret_from_date('2024-11-06 15:24:32');
$b = $sso->calculate_secret_from_date('2024-11-06 15:24:32');
$this->assertSame($a, $b, 'Same date must produce same secret.');
$this->assertNotSame($a, $sso->calculate_secret_from_date('2024-11-07 15:24:32'));
}
public function test_convert_bearer_skips_when_current_blog_not_populated(): void {
global $current_blog;
$original = $current_blog;
$current_blog = new \stdClass();
$current_blog->registered = '';
$sso = SSO::get_instance();
$this->assertNull($sso->convert_bearer_into_auth_cookies(), 'Must return void without throwing when current_blog is half-populated.');
$current_blog = $original;
}Reproduction on staging (see previous comment for the WP-CLI script)
- UM 2.11.0 official (no patch):
SSO_Exception :: La creación del secreto SSO fallówhen$current_blog->registered = ''. - UM 2.11.0 + this PR: returns deterministic secret
cc95d360e9559785ec2d094e951e54a2, no exception.
Staging is kpstage.com, full clone of kursopro.com production: 200+ subsites, several mapped domains (test ran against javieraacademy.com, blog_id 218), PHP 8.3, WP 6.9, LiteSpeed + Cloudflare.
Why this matters for 2.11.x
Networks that combine UM SSO with any iframed admin (WPFA / WP Frontend Admin, frontend admin panels, custom dashboards on mapped domains) will hit blank panel pages immediately after updating 2.10.1 → 2.11.0 on the first cache-miss request to a mapped-domain subsite. The bug is invisible in dev (no Cloudflare, no LiteSpeed, no SaaS-scale cache invalidation) but surfaces predictably in production whenever the page cache cycles.
The patch is 41 lines, single file, zero behavior change on healthy requests, validated in production for 5+ days. It is the minimum surface needed to make 2.11.0 drop-in safe for SaaS-style multisite installs.
Let me know if you want this rebased on any specific branch, or if you prefer the unit tests in a follow-up PR.
Stuck-merge detector: PR has been merge-eligible but unmerged past the thresholdThe pulse merge pass has classified PR #1185 as Failing checks on PR #1185
Worker guidance for the next attempt
Why you're seeing thisEvery pulse cycle (~120s) the deterministic merge pass re-evaluates open PRs. PRs that pass APPROVED + MERGEABLE but fail required checks have historically been re-evaluated silently every cycle until a human noticed. The stuck-merge detector (t3193) surfaces them after Posted automatically by aidevops.sh v3.15.34 automated scan. |
…1169 (subsite password reset) (#1198) Two Cypress specs + matching WP-CLI fixtures that lock in the behaviour of two recently merged fixes so future refactors of the SSO bootstrap chain or the password reset rewrite can't silently regress them. ## 066-sso-bootstrap-race.spec.js (guards PR #1185) Verifies: - `calculate_secret_from_date('')` does NOT throw and returns a hash. - Two consecutive calls with empty input return the SAME hash (deterministic fallback — important so SSO state stays consistent across requests during a bootstrap window). - `convert_bearer_into_auth_cookies()` does NOT throw when `$current_blog` exists with an empty `registered` property. Driven via fixture `setup-sso-bootstrap-race.php` which calls both methods through the live `SSO::get_instance()` singleton and emits JSON. ## 011-password-reset-subsite-domain.spec.js (guards PR #1169) Verifies: - The URL produced by `retrieve_password_message` on a subsite uses the subsite host (or at least no longer points at `/wp-login.php` on a different host). - The reset query args `action / key / login / wp_lang` are preserved so WooCommerce my-account, BuddyPress, custom themes, and the default wp-login fallback can still pick the request up. - The new `wu_subsite_password_reset_url` filter (added by #1169) is reachable for integration overrides. Driven via fixture `setup-password-reset-subsite.php` which creates a test subsite, switches into it, and applies the filter chain directly with a synthetic raw message — no SMTP / Mailpit / cron dependency. Co-authored-by: Kenedy Torcatt <kursopro7@gmail.com>
…1280) * chore: upgrade planning templates * ci(e2e): run the 9 cypress specs that existed but were never invoked The e2e workflow listed only 6 of the 16 spec files in tests/e2e/cypress/integration/. The other 10 were committed and syntactically valid but never executed in CI, so any regression they guarded against could slip through unnoticed. Audit findings (16 specs total): Already in CI (6, unchanged): - 000-setup.spec.js - 010-manual-checkout-flow.spec.js - 020-free-trial-flow.spec.js - 030-stripe-checkout-flow.spec.js (gated, STRIPE_TEST_SK_KEY) - 040-stripe-renewal-flow.spec.js (gated, STRIPE_TEST_SK_KEY) - 065-sso-redirect-loop.spec.js Added to CI in this change (9): - login.spec.js (sanity) - mail.spec.js (sanity) - wizard.spec.js (drives Setup Wizard UI) - 011-password-reset-subsite-domain.spec.js (regression: PR #1169) - 030-modal-form-error-handling.spec.js (UI: AJAX error handling) - 050-password-strength-enforcement.spec.js (UI: zxcvbn scoring) - 060-sso-cross-domain.spec.js (SSO: domain mapping) - 066-sso-bootstrap-race.spec.js (regression: PR #1185) - 035-paypal-checkout-flow.spec.js (gated, new PAYPAL_SANDBOX_*) Intentionally excluded (1): - installation.spec.js — single ‘cy.visit(/wp-admin/)’ call with no assertions; doesn’t actually test anything. Recommend deletion in a follow-up; not removed here to keep this change ci-only. Why wizard.spec.js matters specifically: 000-setup.spec.js bypasses the Setup Wizard UI by calling the installer + setup-finished flag directly. The path Setup_Wizard_Admin_Page::handle_save_settings -> Settings::save_settings -> Field::set_value -> validate_textarea_field was therefore never exercised in CI. The recent fix/textarea-array-coercion fix added unit-test coverage for the validator itself; running wizard.spec.js now covers the integration path. Step organisation (after the existing ‘Run Setup Test’): 1. Sanity Tests — login, mail (smoke) 2. Wizard Test — wizard 3. Regression & UI — 011, 030, 050, 066 4. Checkout Tests — 010, 020 (existing) 5. SSO Tests — 060 (new), 065 (was: only 065) 6. Stripe Tests — 030, 040 (existing, gated) 7. PayPal Tests — 035 (new, gated on PAYPAL_SANDBOX_*) All new groups follow the existing pattern: ‘set +e’ + per-spec loop + aggregated failure count + final exit 1 if any failed, so a single flake doesn’t mask other failures. PayPal step mirrors the Stripe gating: if PAYPAL_SANDBOX_CLIENT_SECRET is unavailable (typical for fork PRs) the step prints the same diagnostic message Stripe uses and exits 0. The PAYPAL_SANDBOX_* repo secrets are not currently configured upstream, so PayPal will skip until they are added; Stripe continues to run. Verification performed in this change: - YAML parse: python3 -c 'import yaml; yaml.safe_load(open(path))' OK - Per-spec node --check on all 9 added specs OK - All referenced fixture .php files exist in tests/e2e/cypress/fixtures - Inventory diff: workflow now references 15/16 specs Local Cypress execution is not attempted in this commit because each matrix variant requires a fresh wp-env (Docker download + WP install + plugin activation + multi-spec run) and would take longer than the CI run that gates the merge. CI is the canonical verification surface for this change. * ci(e2e): disable custom login page after wizard spec The wizard's Default Content step creates a Login page and points Ultimate Multisite's custom-login feature at it, which redirects /wp-login.php -> /login/. The custom page has no #rememberme checkbox, so cy.loginByForm() fails in every subsequent spec's session-setup hook. 000-setup.spec.js already calls setup-disable-custom-login.php defensively in its before() hook for the same reason; this CI step runs the same fixture right after wizard.spec.js so the regression, checkout, SSO, and gated gateway groups inherit a clean login state. Observed on PR #1280 cypress matrix run 26456938227 where 030-modal-form-error-handling failed in its session-setup hook with 'Expected to find element: #rememberme, but never found it', cascading into every subsequent step being skipped.
Summary
Two defensive guards in
inc/sso/class-sso.phpthat preventSSO_Exceptionwhenconvert_bearer_into_auth_cookies()runs before$current_blogis fully populated on multisite.Bug
The SSO callback is attached to
init. On some multisite bootstraps the$GLOBALS['current_blog']object exists, but its properties (includingregistered) are still empty.get_broker()then callscalculate_secret_from_date($current_blog->registered)with an empty string, which throwsSSO_Exception. Because the exception is uncaught at that level, the parent response becomes a 500 / blank page. The most user-visible symptom is admin pages loaded inside an iframe via SSO render blank.Reproduced reliably on:
$current_blogpopulated, works fine).sunrise.phpandms-settings.php.sunriseandms-settings.Fix
convert_bearer_into_auth_cookies()— bail out early when the global blog object is missing or itsregisteredproperty is empty. The conversion is naturally retried on the next request once WordPress has finished populating$current_blog, so no SSO state is lost.calculate_secret_from_date()— when$dateis 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->registeredbeforesunrise.phpfinishes) 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.
Production validation
Deployed on a 300+ subsite multisite (kursopro.com) since 2026-05-07. 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.
Test plan
php -l).$current_blog->registeredis populated (default path).calculate_secret_from_date()returns the same hash for a given main-site registered date when called with empty$date(deterministic fallback).Notes
Independent from #1169 / #1168 — does not touch password reset paths. Targets
inc/sso/class-sso.phponly.Summary by CodeRabbit