Skip to content

Commit 76928bd

Browse files
authored
fix: stabilize playground sudo demo
Fix front-end admin bar sudo deactivation, stabilize Playground authentication, seed realistic demo activity, and update docs/link metadata.
1 parent 6495b26 commit 76928bd

11 files changed

Lines changed: 289 additions & 67 deletions

admin/js/wp-sudo-challenge.js

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,26 @@
1919
// The challenge page must render at the top level so the full admin
2020
// chrome is visible and the redirect/replay targets the correct frame.
2121
if (window.top !== window.self) {
22-
window.top.location.href = window.location.href;
23-
return;
22+
var canNavigateTop = true;
23+
try {
24+
var frame = window.frameElement;
25+
if (frame && frame.hasAttribute('sandbox')) {
26+
var sandboxTokens = (frame.getAttribute('sandbox') || '').split(/\s+/);
27+
canNavigateTop = sandboxTokens.indexOf('allow-top-navigation') !== -1;
28+
}
29+
} catch (e) {
30+
canNavigateTop = false;
31+
}
32+
33+
if (canNavigateTop) {
34+
try {
35+
window.top.location.href = window.location.href;
36+
return;
37+
} catch (e) {
38+
// Cross-origin/sandboxed hosts such as WordPress Playground must
39+
// keep the challenge functional inside the embedded frame.
40+
}
41+
}
2442
}
2543

2644
var config = window.wpSudoChallenge || {};

blueprint.json

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,41 @@
11
{
22
"$schema": "https://playground.wordpress.net/blueprint-schema.json",
33
"preferredVersions": {
4+
"php": "8.2",
45
"wp": "7.0-RC1"
56
},
67
"landingPage": "/wp-admin/",
78
"steps": [
8-
{ "step": "login", "username": "admin", "password": "password" },
9+
{
10+
"step": "runPHP",
11+
"code": "<?php require_once '/wordpress/wp-load.php'; global $wpdb; $user = get_user_by('login', 'admin'); if ($user) { $wpdb->update($wpdb->users, array('user_pass' => wp_hash_password('password')), array('ID' => $user->ID)); clean_user_cache($user); }"
12+
},
13+
{
14+
"step": "login",
15+
"username": "admin",
16+
"password": "password"
17+
},
918
{
1019
"step": "installPlugin",
11-
"pluginZipFile": {
12-
"resource": "url",
13-
"url": "https://github.com/dknauss/Sudo/archive/refs/tags/v3.1.1.zip"
20+
"pluginData": {
21+
"resource": "git:directory",
22+
"url": "https://github.com/dknauss/Sudo",
23+
"ref": "main",
24+
"refType": "branch"
1425
},
1526
"options": { "activate": true }
1627
},
1728
{
1829
"step": "installPlugin",
19-
"pluginZipFile": {
30+
"pluginData": {
2031
"resource": "wordpress.org/plugins",
2132
"slug": "two-factor"
2233
},
2334
"options": { "activate": true }
2435
},
2536
{
2637
"step": "runPHP",
27-
"code": "<?php require_once 'wordpress/wp-load.php'; $expires = time() + 900; update_user_meta(1, '_wp_sudo_expires', $expires); update_user_meta(1, '_wp_sudo_token', wp_hash(wp_generate_password(32, true, true))); $users = array( array('janesmith', 'jane@example.com', 'Jane', 'Smith', 'editor'), array('bobdev', 'bob@example.com', 'Bob', 'Developer', 'author'), array('carlosadmin', 'carlos@example.com', 'Carlos', 'García', 'administrator'), array('sarahops', 'sarah@example.com', 'Sarah', 'Nakamura', 'editor'), array('liwei', 'li@example.com', 'Li', 'Wei', 'contributor'), array('mariadev', 'maria@example.com', 'Maria', 'Santos', 'administrator'), array('alexkim', 'alex@example.com', 'Alex', 'Kim', 'editor'), array('priyapatel', 'priya@example.com', 'Priya', 'Patel', 'author'), array('tomjones', 'tom@example.com', 'Tom', 'Jones', 'subscriber'), array('ninafrost', 'nina@example.com', 'Nina', 'Frost', 'editor') ); foreach ($users as $u) { $uid = wp_insert_user(array('user_login' => $u[0], 'user_email' => $u[1], 'user_pass' => 'password', 'first_name' => $u[2], 'last_name' => $u[3], 'role' => $u[4])); if (!is_wp_error($uid)) { update_user_meta($uid, '_wp_sudo_expires', $expires); update_user_meta($uid, '_wp_sudo_token', wp_hash(wp_generate_password(32, true, true))); } } update_user_meta(1, 'meta-box-order_dashboard', array('side' => 'wp_sudo_activity,dashboard_quick_press,dashboard_primary', 'normal' => 'dashboard_site_health,dashboard_right_now,dashboard_activity')); update_user_meta(1, 'screen_layout_dashboard', 2); echo 'Created 10 test users with active sudo sessions';"
38+
"code": "<?php require_once '/wordpress/wp-load.php'; $admin = get_user_by('login', 'admin'); $admin_id = $admin ? (int) $admin->ID : 1; if ($admin) { foreach (array('_wp_sudo_expires', '_wp_sudo_token', '_wp_sudo_failed_attempts', '_wp_sudo_lockout_until', '_wp_sudo_failure_event', '_wp_sudo_throttle_until', '_two_factor_enabled_providers', '_two_factor_provider', '_two_factor_totp_key', '_two_factor_backup_codes') as $key) { delete_user_meta($admin_id, $key); } } $session_lengths = array('janesmith' => 5, 'bobdev' => 7, 'carlosadmin' => 10, 'sarahops' => 12, 'mariadev' => 15); $demo_users = array(array('janesmith', 'jane@example.com', 'Jane', 'Smith', 'editor', true), array('bobdev', 'bob@example.com', 'Bob', 'Developer', 'author', true), array('carlosadmin', 'carlos@example.com', 'Carlos', 'García', 'administrator', true), array('sarahops', 'sarah@example.com', 'Sarah', 'Nakamura', 'editor', true), array('liwei', 'li@example.com', 'Li', 'Wei', 'contributor', false), array('mariadev', 'maria@example.com', 'Maria', 'Santos', 'administrator', true), array('alexkim', 'alex@example.com', 'Alex', 'Kim', 'editor', false), array('priyapatel', 'priya@example.com', 'Priya', 'Patel', 'author', false)); $user_ids = array(); foreach ($demo_users as $u) { $existing = get_user_by('login', $u[0]); $uid = $existing ? (int) $existing->ID : wp_insert_user(array('user_login' => $u[0], 'user_email' => $u[1], 'user_pass' => 'password', 'first_name' => $u[2], 'last_name' => $u[3], 'display_name' => trim($u[2] . ' ' . $u[3]), 'role' => $u[4])); if (!is_wp_error($uid)) { $uid = (int) $uid; $user_ids[$u[0]] = $uid; wp_update_user(array('ID' => $uid, 'user_email' => $u[1], 'first_name' => $u[2], 'last_name' => $u[3], 'display_name' => trim($u[2] . ' ' . $u[3]), 'role' => $u[4])); if ($u[5]) { $minutes = $session_lengths[$u[0]] ?? 15; update_user_meta($uid, '_wp_sudo_expires', time() + ($minutes * MINUTE_IN_SECONDS)); update_user_meta($uid, '_wp_sudo_token', wp_hash(wp_generate_password(32, true, true))); } else { delete_user_meta($uid, '_wp_sudo_expires'); delete_user_meta($uid, '_wp_sudo_token'); } } } if (class_exists('\\WP_Sudo\\Event_Store')) { \\WP_Sudo\\Event_Store::maybe_create_table(); \\WP_Sudo\\Event_Store::bulk_insert(array(array('user_id' => $admin_id, 'event' => 'action_gated', 'rule_id' => 'options.wp_sudo', 'surface' => 'admin', 'ip' => '127.0.0.1', 'context' => array('demo' => true), 'created_at' => gmdate('Y-m-d H:i:s', time() - 90)), array('user_id' => $admin_id, 'event' => 'action_replayed', 'rule_id' => 'options.wp_sudo', 'surface' => '', 'ip' => '127.0.0.1', 'context' => array('demo' => true), 'created_at' => gmdate('Y-m-d H:i:s', time() - 70)), array('user_id' => $user_ids['mariadev'] ?? $admin_id, 'event' => 'action_passed', 'rule_id' => 'plugin.activate', 'surface' => 'admin', 'ip' => '127.0.0.1', 'context' => array('demo' => true), 'created_at' => gmdate('Y-m-d H:i:s', time() - 240)), array('user_id' => $user_ids['carlosadmin'] ?? $admin_id, 'event' => 'action_gated', 'rule_id' => 'user.delete', 'surface' => 'admin', 'ip' => '127.0.0.1', 'context' => array('demo' => true), 'created_at' => gmdate('Y-m-d H:i:s', time() - 520)), array('user_id' => $user_ids['sarahops'] ?? $admin_id, 'event' => 'action_blocked', 'rule_id' => 'auth.app_password', 'surface' => 'rest_app_password', 'ip' => '127.0.0.1', 'context' => array('demo' => true), 'created_at' => gmdate('Y-m-d H:i:s', time() - 960)), array('user_id' => $user_ids['bobdev'] ?? $admin_id, 'event' => 'action_allowed', 'rule_id' => 'tools.export', 'surface' => 'cli', 'ip' => '127.0.0.1', 'context' => array('demo' => true), 'created_at' => gmdate('Y-m-d H:i:s', time() - 1500)), array('user_id' => $user_ids['janesmith'] ?? $admin_id, 'event' => 'action_blocked', 'rule_id' => 'options.critical', 'surface' => 'xmlrpc', 'ip' => '127.0.0.1', 'context' => array('demo' => true), 'created_at' => gmdate('Y-m-d H:i:s', time() - 2300)), array('user_id' => $user_ids['liwei'] ?? $admin_id, 'event' => 'lockout', 'rule_id' => '', 'surface' => '', 'ip' => '127.0.0.1', 'context' => array('demo' => true, 'attempts' => 5), 'created_at' => gmdate('Y-m-d H:i:s', time() - 3600)))); } delete_transient('wp_sudo_active_sessions_' . (function_exists('get_current_blog_id') ? (int) get_current_blog_id() : 0)); update_user_meta($admin_id, 'meta-box-order_dashboard', array('side' => 'wp_sudo_activity,dashboard_quick_press,dashboard_primary', 'normal' => 'dashboard_site_health,dashboard_right_now,dashboard_activity')); update_user_meta($admin_id, 'screen_layout_dashboard', 2); echo 'Prepared WP Sudo demo data. Admin password: password. Sudo challenge password: password. Demo sessions: 5-15 minutes.';"
2839
}
2940
]
3041
}

docs/current-metrics.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22

33
This file is the single source of truth for current repository counts.
44

5-
Last verified: 2026-05-10
6-
Verification environment: primary local repo checkout at `/Users/dan-knauss/Code/Sudo` using ephemeral pkgx PHP/Composer runtime
5+
Last verified: 2026-05-11
6+
Verification environment: GitHub Actions checkout for PR #38 using `composer verify:metrics`
77

88
## Test Metrics
99

1010
| Metric | Value | Verification |
1111
|---|---:|---|
12-
| Unit tests | 662 tests | `composer test:unit` |
13-
| Unit assertions | 1938 assertions | `composer test:unit` |
12+
| Unit tests | 664 tests | `composer test:unit` |
13+
| Unit assertions | 1954 assertions | `composer test:unit` |
1414
| Integration tests in suite | 160 test methods | `rg -c "function test" tests/Integration/*.php | awk -F: '{sum+=$2} END{print sum}'` |
1515
| Unit test files | 23 | `ls tests/Unit/*.php | wc -l` |
1616
| Integration test files | 22 | `ls tests/Integration/*.php | wc -l` |
@@ -19,11 +19,11 @@ Verification environment: primary local repo checkout at `/Users/dan-knauss/Code
1919

2020
| Metric | Value | Verification |
2121
|---|---:|---|
22-
| Production PHP lines (`includes/`, `wp-sudo.php`, `uninstall.php`, `mu-plugin/`, `bridges/`) | 13,060 | `find ./includes ./wp-sudo.php ./uninstall.php ./mu-plugin ./bridges -type f -name "*.php" -print0 | xargs -0 wc -l | tail -1 | awk '{print $1}'` |
23-
| Tests PHP lines (`tests/`) | 23,477 | `find ./tests -type f -name "*.php" -print0 | xargs -0 wc -l | tail -1 | awk '{print $1}'` |
24-
| Production + tests PHP lines | 36,537 | sum of the two rows above |
25-
| Test-to-production ratio | 1.80:1 | `23477 / 13060` |
26-
| Total repo PHP lines (excluding `vendor/`, `vendor_test/`, `.tmp/`, `.git/`) | 36,800 | `find . -type f -name "*.php" ! -path "*/vendor/*" ! -path "*/vendor_test/*" ! -path "*/.tmp/*" ! -path "*/.git/*" -print0 | xargs -0 wc -l | tail -1 | awk '{print $1}'` |
22+
| Production PHP lines (`includes/`, `wp-sudo.php`, `uninstall.php`, `mu-plugin/`, `bridges/`) | 13,126 | `find ./includes ./wp-sudo.php ./uninstall.php ./mu-plugin ./bridges -type f -name "*.php" -print0 | xargs -0 wc -l | tail -1 | awk '{print $1}'` |
23+
| Tests PHP lines (`tests/`) | 23,559 | `find ./tests -type f -name "*.php" -print0 | xargs -0 wc -l | tail -1 | awk '{print $1}'` |
24+
| Production + tests PHP lines | 36,685 | sum of the two rows above |
25+
| Test-to-production ratio | 1.79:1 | `23559 / 13126` |
26+
| Total repo PHP lines (excluding `vendor/`, `vendor_test/`, `.tmp/`, `.git/`) | 36,948 | `find . -type f -name "*.php" ! -path "*/vendor/*" ! -path "*/vendor_test/*" ! -path "*/.tmp/*" ! -path "*/.git/*" -print0 | xargs -0 wc -l | tail -1 | awk '{print $1}'` |
2727

2828
## Architectural Facts
2929

@@ -66,7 +66,7 @@ Source: `.github/workflows/phpunit.yml`, `.github/workflows/e2e.yml`, `.github/w
6666

6767
## Verification Notes
6868

69-
- `composer test:unit` passed on 2026-05-10 (`662 tests`, `1938 assertions`).
69+
- `composer test:unit` passed on 2026-05-11 (`664 tests`, `1954 assertions`).
7070
- `composer test:integration` passed on 2026-04-20 (`165 tests`, `538 assertions`, `9 skipped`) using the repo wrapper's `wp-env` `tests-cli` fallback against the containerized `wordpress_test` database.
7171
- `WP_MULTISITE=1 composer test:integration` passed on 2026-04-20 (`165 tests`, `552 assertions`, `2 skipped`) using the same `wp-env` `tests-cli` fallback and database.
7272
- `composer analyse:phpstan` and `composer lint` passed on 2026-05-10.

includes/class-admin-bar.php

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,21 @@ class Admin_Bar {
3535
*/
3636
public const DEACTIVATE_PARAM = 'wp_sudo_deactivate';
3737

38+
/**
39+
* Query parameter for the post-deactivation redirect target.
40+
*
41+
* @var string
42+
*/
43+
public const REDIRECT_PARAM = 'wp_sudo_redirect_to';
44+
3845
/**
3946
* Register hooks.
4047
*
4148
* @return void
4249
*/
4350
public function register(): void {
4451
add_action( 'admin_bar_menu', array( $this, 'admin_bar_node' ), 100 );
45-
add_action( 'admin_init', array( $this, 'handle_deactivate' ), 5, 0 );
52+
add_action( 'init', array( $this, 'handle_deactivate' ), 5, 0 );
4653
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ), 10, 0 );
4754
}
4855

@@ -72,13 +79,16 @@ public function admin_bar_node( $wp_admin_bar ): void {
7279
$minutes = floor( $remaining / 60 );
7380
$seconds = $remaining % 60;
7481

75-
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Fallback handles missing key.
76-
$current_url = isset( $_SERVER['REQUEST_URI'] )
77-
? set_url_scheme( home_url( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) ) )
78-
: admin_url();
82+
$current_url = self::current_url();
7983

8084
$deactivate_url = wp_nonce_url(
81-
add_query_arg( self::DEACTIVATE_PARAM, '1', $current_url ),
85+
add_query_arg(
86+
array(
87+
self::DEACTIVATE_PARAM => '1',
88+
self::REDIRECT_PARAM => $current_url,
89+
),
90+
admin_url()
91+
),
8292
self::DEACTIVATE_NONCE,
8393
'_wpnonce'
8494
);
@@ -138,10 +148,53 @@ public function handle_deactivate(): void {
138148

139149
Sudo_Session::deactivate( $user_id );
140150

141-
wp_safe_redirect( remove_query_arg( array( self::DEACTIVATE_PARAM, '_wpnonce' ) ) );
151+
wp_safe_redirect( self::deactivation_redirect_url() );
142152
exit;
143153
}
144154

155+
/**
156+
* Resolve the current URL used as the return target after deactivation.
157+
*
158+
* @return string
159+
*/
160+
private static function current_url(): string {
161+
if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
162+
return admin_url();
163+
}
164+
165+
$scheme = is_ssl() ? 'https' : 'http';
166+
$host = sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ?? '' ) );
167+
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- esc_url_raw() sanitizes the full URL; sanitize_text_field() would corrupt encoded path/query segments.
168+
$uri = wp_unslash( $_SERVER['REQUEST_URI'] );
169+
170+
if ( '' === $host ) {
171+
return admin_url();
172+
}
173+
174+
return esc_url_raw( $scheme . '://' . $host . $uri );
175+
}
176+
177+
/**
178+
* Resolve and clean the post-deactivation redirect URL.
179+
*
180+
* @return string
181+
*/
182+
private static function deactivation_redirect_url(): string {
183+
$redirect_url = admin_url();
184+
185+
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce is verified before this helper is called.
186+
if ( isset( $_GET[ self::REDIRECT_PARAM ] ) && is_string( $_GET[ self::REDIRECT_PARAM ] ) ) {
187+
// phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified; esc_url_raw() sanitizes the full URL.
188+
$candidate = esc_url_raw( wp_unslash( $_GET[ self::REDIRECT_PARAM ] ) );
189+
190+
if ( '' !== $candidate ) {
191+
$redirect_url = $candidate;
192+
}
193+
}
194+
195+
return remove_query_arg( array( self::DEACTIVATE_PARAM, self::REDIRECT_PARAM, '_wpnonce' ), $redirect_url );
196+
}
197+
145198
/**
146199
* Enqueue admin bar assets when session is active.
147200
*

includes/class-dashboard-widget.php

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ class Dashboard_Widget {
3838
*/
3939
public static function init(): void {
4040
add_action( 'wp_dashboard_setup', array( self::class, 'register' ) );
41+
add_action( 'wp_sudo_activated', array( self::class, 'flush_active_sessions_cache' ), 10, 0 );
42+
add_action( 'wp_sudo_deactivated', array( self::class, 'flush_active_sessions_cache' ), 10, 0 );
4143
}
4244

4345
/**
@@ -167,9 +169,6 @@ private static function render_active_sessions(): void {
167169
return;
168170
}
169171

170-
// phpcs:ignore WordPress.WP.I18n.MissingTranslatorsComment -- Simple count.
171-
echo '<p class="wp-sudo-active-count"><strong>' . esc_html( sprintf( _n( '%d active session', '%d active sessions', $count, 'wp-sudo' ), $count ) ) . '</strong></p>';
172-
173172
echo '<ul class="wp-sudo-user-list">';
174173
foreach ( $users as $user ) {
175174
if ( ! is_object( $user ) || ! isset( $user->ID ) ) {
@@ -236,6 +235,20 @@ private static function render_active_sessions(): void {
236235
}
237236
}
238237

238+
/**
239+
* Clear the active-sessions payload cache for the current site.
240+
*
241+
* @return void
242+
*/
243+
public static function flush_active_sessions_cache(): void {
244+
if ( ! function_exists( 'delete_transient' ) ) {
245+
return;
246+
}
247+
248+
$blog_id = function_exists( 'get_current_blog_id' ) ? (int) get_current_blog_id() : 0;
249+
delete_transient( self::ACTIVE_SESSIONS_CACHE_KEY . $blog_id );
250+
}
251+
239252
/**
240253
* Return the active-sessions payload, using a short-TTL transient cache.
241254
*

readme.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
WP Sudo adds **action-gated reauthentication** to WordPress so high-risk operations require fresh confirmation before they proceed.
44

5-
[![License: GPL v2+](https://img.shields.io/badge/License-GPL%20v2%2B-blue.svg)](https://www.gnu.org/licenses/gpl-2.0.html)
5+
[![License: GPL v2+](https://img.shields.io/badge/License-GPL%20v2%2B-blue.svg)](https://spdx.org/licenses/GPL-2.0-or-later.html)
66
[![WordPress: 6.2+](https://img.shields.io/badge/WordPress-6.2%2B-0073aa.svg)](https://wordpress.org/)
77
[![PHP: 8.0+](https://img.shields.io/badge/PHP-8.0%2B-777bb4.svg)](https://www.php.net/)
88
[![PHPUnit](https://github.com/dknauss/Sudo/actions/workflows/phpunit.yml/badge.svg)](https://github.com/dknauss/Sudo/actions/workflows/phpunit.yml)
@@ -13,6 +13,8 @@ WP Sudo adds **action-gated reauthentication** to WordPress so high-risk operati
1313
[![Type Coverage](https://shepherd.dev/github/dknauss/Sudo/coverage.svg)](https://shepherd.dev/github/dknauss/Sudo)
1414
[![Try in Playground](https://img.shields.io/badge/Try%20it-Playground-3858e9?logo=wordpress&logoColor=white)](https://playground.wordpress.net/?blueprint-url=https://raw.githubusercontent.com/dknauss/Sudo/main/blueprint.json)
1515

16+
Playground demo credentials are `admin` / `password`. When WP Sudo asks for reauthentication, enter the same password: `password`.
17+
1618
> **3.1.1 hardening release:** WP Sudo tightens role-change interception, sensitive request replay, MU-plugin loading, audit bridge parity, and development dependency security. See [docs/release-status.md](docs/release-status.md) for current release posture.
1719
1820
## What’s new in 3.1.1

readme.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ _WP Sudo 3.1.1 is a hardening release for action-gated reauthentication, tighten
22

33
[Try it in WordPress Playground](https://playground.wordpress.net/?blueprint-url=https://raw.githubusercontent.com/dknauss/Sudo/main/blueprint.json)
44

5+
Playground demo credentials are `admin` / `password`. When WP Sudo asks for reauthentication, enter the same password: `password`.
6+
57
=== Sudo ===
68
Contributors: dpknauss
79
Donate link: https://dan.knauss.ca
@@ -11,7 +13,7 @@ Tested up to: 6.9
1113
Requires PHP: 8.0
1214
Stable tag: 3.1.1
1315
License: GPL-2.0-or-later
14-
License URI: https://www.gnu.org/licenses/gpl-2.0.html
16+
License URI: https://spdx.org/licenses/GPL-2.0-or-later.html
1517

1618
WordPress security plugins guard the door. Sudo governs what can happen inside the house.
1719

0 commit comments

Comments
 (0)