PluginProbe
phpinfo() WP – Site Health, PHP Compatibility & Server Audit / 7.2.5
phpinfo() WP – Site Health, PHP Compatibility & Server Audit v7.2.5
7.2.7 7.2.6 7.2.5 7.2.4 7.2.3 7.2.0 7.2.1 7.2.2 7.1.0 7.0.3 7.0.4 7.0.5 trunk 6.0 7.0.0 7.0.1 7.0.2
phpinfo-wp / includes / class-safemode.php

class-safemode.php in phpinfo() WP – Site Health, PHP Compatibility & Server Audit 7.2.5, at includes/class-safemode.php

272 lines 10.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 defined('ABSPATH') or die('Unauthorized Access');
3
4 /**
5 * Troubleshooting Mode (Safemode) — the Health Check killer feature.
6 *
7 * - Per-user: only the admin who started a session sees plugins disabled
8 * (cookie scoped to them). Other visitors see the site normally.
9 * - Time-limited: cookies and transients expire after the chosen duration.
10 * Default 1 hour, max 4 hours.
11 * - Fully reversible: nothing in the database is ever modified. The actual
12 * active_plugins option is never touched. Filters do the work at runtime.
13 * - mu-plugin drop: installs a tiny must-use plugin at session start so the
14 * filter fires before regular plugins load (true code-level isolation).
15 * Without the mu-plugin we fall back to "admin-pages only" isolation which
16 * is still useful for debugging the dashboard.
17 * - Explicit exit: an "End and restore" button kills the cookie + transient.
18 * Even if the user closes the browser, the session expires on its own.
19 *
20 * The famous Health Check bug — "exited troubleshooting and all my plugins
21 * stayed disabled" — cannot happen here because we never deactivate plugins
22 * in the database. Worst-case (cookie stuck, no UI access) the user clears
23 * cookies and everything is back to normal.
24 */
25 class Phpinfo_WP_Safemode {
26
27 const COOKIE = 'phpinfowp_safemode';
28 const TRANSIENT_PREFIX = 'phpinfowp_safemode_';
29 const DEFAULT_DURATION = 3600; // 1 hour
30 const MAX_DURATION = 14400; // 4 hours
31 const MU_FILE = 'phpinfowp-safemode.php';
32
33 public static function register(): void {
34 // Apply filters from main plugin too — covers admin pages even when
35 // the mu-plugin isn't installed. The mu-plugin gives full coverage.
36 $token = self::current_token();
37 if (!$token) return;
38
39 $session = self::load_session($token);
40 if (!$session) {
41 // Stale cookie → clean up to avoid confusing users
42 self::clear_cookie();
43 return;
44 }
45
46 // Make the live session globally accessible for filters + views
47 $GLOBALS['phpinfowp_safemode_session'] = $session;
48
49 add_filter('option_active_plugins', [self::class, 'filter_active_plugins'], 0);
50 add_filter('site_option_active_sitewide_plugins',[self::class, 'filter_sitewide_plugins'], 0);
51
52 if (!empty($session['disable_theme'])) {
53 add_filter('stylesheet', [self::class, 'default_theme'], 0);
54 add_filter('template', [self::class, 'default_theme'], 0);
55 }
56
57 // Auto-exit on logout — be sure to nuke the cookie when the user
58 // logs out of the admin, since their identity check no longer applies.
59 add_action('wp_logout', [self::class, 'stop']);
60 }
61
62 // -- Session lifecycle -------------------------------------------------
63
64 public static function start(array $disabled_plugins, bool $disable_theme = false, int $duration = self::DEFAULT_DURATION): array {
65 if (!current_user_can('manage_options')) {
66 return ['ok' => false, 'reason' => 'insufficient_caps'];
67 }
68
69 $duration = max(60, min($duration, self::MAX_DURATION));
70 $token = bin2hex(random_bytes(16));
71
72 $session = [
73 'user_id' => get_current_user_id(),
74 'disabled_plugins' => array_values(array_filter(array_map('sanitize_text_field', $disabled_plugins))),
75 'disable_theme' => (bool) $disable_theme,
76 'started' => time(),
77 'expires' => time() + $duration,
78 'duration' => $duration,
79 ];
80
81 set_transient(self::TRANSIENT_PREFIX . $token, $session, $duration);
82 self::set_cookie($token, $session['expires']);
83
84 $mu = self::install_mu_plugin();
85 return ['ok' => true, 'token' => $token, 'mu' => $mu, 'session' => $session];
86 }
87
88 public static function stop(): void {
89 $token = self::current_token();
90 if ($token) delete_transient(self::TRANSIENT_PREFIX . $token);
91 self::clear_cookie();
92 unset($GLOBALS['phpinfowp_safemode_session']);
93 }
94
95 public static function is_active(): bool {
96 return self::current_session() !== null;
97 }
98
99 public static function current_session(): ?array {
100 $token = self::current_token();
101 if (!$token) return null;
102 return self::load_session($token);
103 }
104
105 private static function current_token(): ?string {
106 $t = $_COOKIE[self::COOKIE] ?? '';
107 if (!is_string($t) || !preg_match('/^[a-f0-9]{32}$/', $t)) return null;
108 return $t;
109 }
110
111 private static function load_session(string $token): ?array {
112 $session = get_transient(self::TRANSIENT_PREFIX . $token);
113 if (!is_array($session)) return null;
114 if ((int) ($session['expires'] ?? 0) < time()) {
115 delete_transient(self::TRANSIENT_PREFIX . $token);
116 return null;
117 }
118 // If a different user is now logged in, refuse to apply this session.
119 // We don't clear the cookie here — the original user might log back in.
120 $current_uid = get_current_user_id();
121 if ($current_uid > 0 && (int) ($session['user_id'] ?? 0) !== $current_uid) {
122 return null;
123 }
124 return $session;
125 }
126
127 // -- Cookie handling ---------------------------------------------------
128
129 private static function set_cookie(string $token, int $expires): void {
130 $secure = is_ssl();
131 $opts = [
132 'expires' => $expires,
133 'path' => COOKIEPATH ?: '/',
134 'domain' => COOKIE_DOMAIN ?: '',
135 'secure' => $secure,
136 'httponly' => true,
137 'samesite' => 'Lax',
138 ];
139 if (!headers_sent()) setcookie(self::COOKIE, $token, $opts);
140 $_COOKIE[self::COOKIE] = $token;
141 }
142
143 private static function clear_cookie(): void {
144 if (!headers_sent()) {
145 setcookie(self::COOKIE, '', [
146 'expires' => time() - 3600,
147 'path' => COOKIEPATH ?: '/',
148 'domain' => COOKIE_DOMAIN ?: '',
149 ]);
150 }
151 unset($_COOKIE[self::COOKIE]);
152 }
153
154 // -- Filters -----------------------------------------------------------
155
156 public static function filter_active_plugins($plugins) {
157 $s = $GLOBALS['phpinfowp_safemode_session'] ?? null;
158 if (!$s || !is_array($plugins)) return $plugins;
159 return array_values(array_diff($plugins, (array) ($s['disabled_plugins'] ?? [])));
160 }
161
162 public static function filter_sitewide_plugins($plugins) {
163 $s = $GLOBALS['phpinfowp_safemode_session'] ?? null;
164 if (!$s || !is_array($plugins)) return $plugins;
165 foreach ((array) ($s['disabled_plugins'] ?? []) as $p) unset($plugins[$p]);
166 return $plugins;
167 }
168
169 public static function default_theme($theme) {
170 return defined('WP_DEFAULT_THEME') && WP_DEFAULT_THEME ? WP_DEFAULT_THEME : $theme;
171 }
172
173 // -- mu-plugin install / uninstall -------------------------------------
174
175 public static function install_mu_plugin(): array {
176 $dir = self::mu_dir();
177 if (!file_exists($dir) && !@mkdir($dir, 0755, true)) {
178 return ['installed' => false, 'reason' => 'mu-plugins directory could not be created'];
179 }
180 if (!is_dir($dir) || !is_writable($dir)) {
181 return ['installed' => false, 'reason' => 'mu-plugins directory not writable'];
182 }
183 $target = $dir . '/' . self::MU_FILE;
184 $content = self::mu_plugin_source();
185 $ok = (bool) @file_put_contents($target, $content);
186 return [
187 'installed' => $ok,
188 'path' => $target,
189 'reason' => $ok ? 'installed' : 'write failed',
190 ];
191 }
192
193 public static function uninstall_mu_plugin(): bool {
194 $target = self::mu_dir() . '/' . self::MU_FILE;
195 if (!file_exists($target)) return true;
196 return (bool) @unlink($target);
197 }
198
199 public static function mu_plugin_installed(): bool {
200 return file_exists(self::mu_dir() . '/' . self::MU_FILE);
201 }
202
203 private static function mu_dir(): string {
204 return defined('WPMU_PLUGIN_DIR') && WPMU_PLUGIN_DIR
205 ? WPMU_PLUGIN_DIR
206 : WP_CONTENT_DIR . '/mu-plugins';
207 }
208
209 /**
210 * The mu-plugin runs before regular plugins load. It is harmless when no
211 * cookie is present (early return). Hand-rolled to avoid coupling to the
212 * full phpinfo() WP runtime — mu-plugins should be minimal & dependency-free.
213 */
214 private static function mu_plugin_source(): string {
215 return <<<'PHP'
216 <?php
217 /**
218 * phpinfo() WP — Troubleshooting Safemode mu-plugin
219 *
220 * Installed automatically by phpinfo() WP. Filters the active plugins list
221 * and (optionally) the active theme for users carrying a valid safemode
222 * cookie. Harmless when no cookie is set.
223 *
224 * Safe to leave installed. Removed automatically on plugin uninstall.
225 */
226 if (!defined('ABSPATH')) exit;
227
228 $_phpiwp_token = $_COOKIE['phpinfowp_safemode'] ?? '';
229 if (!is_string($_phpiwp_token) || !preg_match('/^[a-f0-9]{32}$/', $_phpiwp_token)) return;
230
231 $_phpiwp_apply = function($_phpiwp_token) {
232 static $session = null;
233 if ($session !== null) return $session ?: null;
234 $s = get_transient('phpinfowp_safemode_' . $_phpiwp_token);
235 if (!is_array($s) || ((int)($s['expires'] ?? 0)) < time()) {
236 $session = false;
237 return null;
238 }
239 $session = $s;
240 return $s;
241 };
242
243 add_filter('option_active_plugins', function($plugins) use ($_phpiwp_token, $_phpiwp_apply) {
244 if (!is_array($plugins)) return $plugins;
245 $s = $_phpiwp_apply($_phpiwp_token);
246 if (!$s) return $plugins;
247 return array_values(array_diff($plugins, (array)($s['disabled_plugins'] ?? [])));
248 }, 0);
249
250 add_filter('site_option_active_sitewide_plugins', function($plugins) use ($_phpiwp_token, $_phpiwp_apply) {
251 if (!is_array($plugins)) return $plugins;
252 $s = $_phpiwp_apply($_phpiwp_token);
253 if (!$s) return $plugins;
254 foreach ((array)($s['disabled_plugins'] ?? []) as $p) unset($plugins[$p]);
255 return $plugins;
256 }, 0);
257
258 add_filter('stylesheet', function($theme) use ($_phpiwp_token, $_phpiwp_apply) {
259 $s = $_phpiwp_apply($_phpiwp_token);
260 if (!$s || empty($s['disable_theme'])) return $theme;
261 return defined('WP_DEFAULT_THEME') && WP_DEFAULT_THEME ? WP_DEFAULT_THEME : $theme;
262 }, 0);
263
264 add_filter('template', function($theme) use ($_phpiwp_token, $_phpiwp_apply) {
265 $s = $_phpiwp_apply($_phpiwp_token);
266 if (!$s || empty($s['disable_theme'])) return $theme;
267 return defined('WP_DEFAULT_THEME') && WP_DEFAULT_THEME ? WP_DEFAULT_THEME : $theme;
268 }, 0);
269 PHP;
270 }
271 }
272