class-wp-recovery-mode.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  1. <?php
  2. /**
  3. * Error Protection API: WP_Recovery_Mode class
  4. *
  5. * @package WordPress
  6. * @since 5.2.0
  7. */
  8. /**
  9. * Core class used to implement Recovery Mode.
  10. *
  11. * @since 5.2.0
  12. */
  13. class WP_Recovery_Mode {
  14. const EXIT_ACTION = 'exit_recovery_mode';
  15. /**
  16. * Service to handle cookies.
  17. *
  18. * @since 5.2.0
  19. * @var WP_Recovery_Mode_Cookie_Service
  20. */
  21. private $cookie_service;
  22. /**
  23. * Service to generate a recovery mode key.
  24. *
  25. * @since 5.2.0
  26. * @var WP_Recovery_Mode_Key_Service
  27. */
  28. private $key_service;
  29. /**
  30. * Service to generate and validate recovery mode links.
  31. *
  32. * @since 5.2.0
  33. * @var WP_Recovery_Mode_Link_Service
  34. */
  35. private $link_service;
  36. /**
  37. * Service to handle sending an email with a recovery mode link.
  38. *
  39. * @since 5.2.0
  40. * @var WP_Recovery_Mode_Email_Service
  41. */
  42. private $email_service;
  43. /**
  44. * Is recovery mode initialized.
  45. *
  46. * @since 5.2.0
  47. * @var bool
  48. */
  49. private $is_initialized = false;
  50. /**
  51. * Is recovery mode active in this session.
  52. *
  53. * @since 5.2.0
  54. * @var bool
  55. */
  56. private $is_active = false;
  57. /**
  58. * Get an ID representing the current recovery mode session.
  59. *
  60. * @since 5.2.0
  61. * @var string
  62. */
  63. private $session_id = '';
  64. /**
  65. * WP_Recovery_Mode constructor.
  66. *
  67. * @since 5.2.0
  68. */
  69. public function __construct() {
  70. $this->cookie_service = new WP_Recovery_Mode_Cookie_Service();
  71. $this->key_service = new WP_Recovery_Mode_Key_Service();
  72. $this->link_service = new WP_Recovery_Mode_Link_Service( $this->cookie_service, $this->key_service );
  73. $this->email_service = new WP_Recovery_Mode_Email_Service( $this->link_service );
  74. }
  75. /**
  76. * Initialize recovery mode for the current request.
  77. *
  78. * @since 5.2.0
  79. */
  80. public function initialize() {
  81. $this->is_initialized = true;
  82. add_action( 'wp_logout', array( $this, 'exit_recovery_mode' ) );
  83. add_action( 'login_form_' . self::EXIT_ACTION, array( $this, 'handle_exit_recovery_mode' ) );
  84. add_action( 'recovery_mode_clean_expired_keys', array( $this, 'clean_expired_keys' ) );
  85. if ( ! wp_next_scheduled( 'recovery_mode_clean_expired_keys' ) && ! wp_installing() ) {
  86. wp_schedule_event( time(), 'daily', 'recovery_mode_clean_expired_keys' );
  87. }
  88. if ( defined( 'WP_RECOVERY_MODE_SESSION_ID' ) ) {
  89. $this->is_active = true;
  90. $this->session_id = WP_RECOVERY_MODE_SESSION_ID;
  91. return;
  92. }
  93. if ( $this->cookie_service->is_cookie_set() ) {
  94. $this->handle_cookie();
  95. return;
  96. }
  97. $this->link_service->handle_begin_link( $this->get_link_ttl() );
  98. }
  99. /**
  100. * Checks whether recovery mode is active.
  101. *
  102. * This will not change after recovery mode has been initialized. {@see WP_Recovery_Mode::run()}.
  103. *
  104. * @since 5.2.0
  105. *
  106. * @return bool True if recovery mode is active, false otherwise.
  107. */
  108. public function is_active() {
  109. return $this->is_active;
  110. }
  111. /**
  112. * Gets the recovery mode session ID.
  113. *
  114. * @since 5.2.0
  115. *
  116. * @return string The session ID if recovery mode is active, empty string otherwise.
  117. */
  118. public function get_session_id() {
  119. return $this->session_id;
  120. }
  121. /**
  122. * Checks whether recovery mode has been initialized.
  123. *
  124. * Recovery mode should not be used until this point. Initialization happens immediately before loading plugins.
  125. *
  126. * @since 5.2.0
  127. *
  128. * @return bool
  129. */
  130. public function is_initialized() {
  131. return $this->is_initialized;
  132. }
  133. /**
  134. * Handles a fatal error occurring.
  135. *
  136. * The calling API should immediately die() after calling this function.
  137. *
  138. * @since 5.2.0
  139. *
  140. * @param array $error Error details from {@see error_get_last()}
  141. * @return true|WP_Error True if the error was handled and headers have already been sent.
  142. * Or the request will exit to try and catch multiple errors at once.
  143. * WP_Error if an error occurred preventing it from being handled.
  144. */
  145. public function handle_error( array $error ) {
  146. $extension = $this->get_extension_for_error( $error );
  147. if ( ! $extension || $this->is_network_plugin( $extension ) ) {
  148. return new WP_Error( 'invalid_source', __( 'Error not caused by a plugin or theme.' ) );
  149. }
  150. if ( ! $this->is_active() ) {
  151. if ( ! is_protected_endpoint() ) {
  152. return new WP_Error( 'non_protected_endpoint', __( 'Error occurred on a non-protected endpoint.' ) );
  153. }
  154. if ( ! function_exists( 'wp_generate_password' ) ) {
  155. require_once ABSPATH . WPINC . '/pluggable.php';
  156. }
  157. return $this->email_service->maybe_send_recovery_mode_email( $this->get_email_rate_limit(), $error, $extension );
  158. }
  159. if ( ! $this->store_error( $error ) ) {
  160. return new WP_Error( 'storage_error', __( 'Failed to store the error.' ) );
  161. }
  162. if ( headers_sent() ) {
  163. return true;
  164. }
  165. $this->redirect_protected();
  166. }
  167. /**
  168. * Ends the current recovery mode session.
  169. *
  170. * @since 5.2.0
  171. *
  172. * @return bool True on success, false on failure.
  173. */
  174. public function exit_recovery_mode() {
  175. if ( ! $this->is_active() ) {
  176. return false;
  177. }
  178. $this->email_service->clear_rate_limit();
  179. $this->cookie_service->clear_cookie();
  180. wp_paused_plugins()->delete_all();
  181. wp_paused_themes()->delete_all();
  182. return true;
  183. }
  184. /**
  185. * Handles a request to exit Recovery Mode.
  186. *
  187. * @since 5.2.0
  188. */
  189. public function handle_exit_recovery_mode() {
  190. $redirect_to = wp_get_referer();
  191. // Safety check in case referrer returns false.
  192. if ( ! $redirect_to ) {
  193. $redirect_to = is_user_logged_in() ? admin_url() : home_url();
  194. }
  195. if ( ! $this->is_active() ) {
  196. wp_safe_redirect( $redirect_to );
  197. die;
  198. }
  199. if ( ! isset( $_GET['action'] ) || self::EXIT_ACTION !== $_GET['action'] ) {
  200. return;
  201. }
  202. if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( $_GET['_wpnonce'], self::EXIT_ACTION ) ) {
  203. wp_die( __( 'Exit recovery mode link expired.' ), 403 );
  204. }
  205. if ( ! $this->exit_recovery_mode() ) {
  206. wp_die( __( 'Failed to exit recovery mode. Please try again later.' ) );
  207. }
  208. wp_safe_redirect( $redirect_to );
  209. die;
  210. }
  211. /**
  212. * Cleans any recovery mode keys that have expired according to the link TTL.
  213. *
  214. * Executes on a daily cron schedule.
  215. *
  216. * @since 5.2.0
  217. */
  218. public function clean_expired_keys() {
  219. $this->key_service->clean_expired_keys( $this->get_link_ttl() );
  220. }
  221. /**
  222. * Handles checking for the recovery mode cookie and validating it.
  223. *
  224. * @since 5.2.0
  225. */
  226. protected function handle_cookie() {
  227. $validated = $this->cookie_service->validate_cookie();
  228. if ( is_wp_error( $validated ) ) {
  229. $this->cookie_service->clear_cookie();
  230. $validated->add_data( array( 'status' => 403 ) );
  231. wp_die( $validated );
  232. }
  233. $session_id = $this->cookie_service->get_session_id_from_cookie();
  234. if ( is_wp_error( $session_id ) ) {
  235. $this->cookie_service->clear_cookie();
  236. $session_id->add_data( array( 'status' => 403 ) );
  237. wp_die( $session_id );
  238. }
  239. $this->is_active = true;
  240. $this->session_id = $session_id;
  241. }
  242. /**
  243. * Gets the rate limit between sending new recovery mode email links.
  244. *
  245. * @since 5.2.0
  246. *
  247. * @return int Rate limit in seconds.
  248. */
  249. protected function get_email_rate_limit() {
  250. /**
  251. * Filter the rate limit between sending new recovery mode email links.
  252. *
  253. * @since 5.2.0
  254. *
  255. * @param int $rate_limit Time to wait in seconds. Defaults to 1 day.
  256. */
  257. return apply_filters( 'recovery_mode_email_rate_limit', DAY_IN_SECONDS );
  258. }
  259. /**
  260. * Gets the number of seconds the recovery mode link is valid for.
  261. *
  262. * @since 5.2.0
  263. *
  264. * @return int Interval in seconds.
  265. */
  266. protected function get_link_ttl() {
  267. $rate_limit = $this->get_email_rate_limit();
  268. $valid_for = $rate_limit;
  269. /**
  270. * Filter the amount of time the recovery mode email link is valid for.
  271. *
  272. * The ttl must be at least as long as the email rate limit.
  273. *
  274. * @since 5.2.0
  275. *
  276. * @param int $valid_for The number of seconds the link is valid for.
  277. */
  278. $valid_for = apply_filters( 'recovery_mode_email_link_ttl', $valid_for );
  279. return max( $valid_for, $rate_limit );
  280. }
  281. /**
  282. * Gets the extension that the error occurred in.
  283. *
  284. * @since 5.2.0
  285. *
  286. * @global array $wp_theme_directories
  287. *
  288. * @param array $error Error that was triggered.
  289. *
  290. * @return array|false {
  291. * @type string $slug The extension slug. This is the plugin or theme's directory.
  292. * @type string $type The extension type. Either 'plugin' or 'theme'.
  293. * }
  294. */
  295. protected function get_extension_for_error( $error ) {
  296. global $wp_theme_directories;
  297. if ( ! isset( $error['file'] ) ) {
  298. return false;
  299. }
  300. if ( ! defined( 'WP_PLUGIN_DIR' ) ) {
  301. return false;
  302. }
  303. $error_file = wp_normalize_path( $error['file'] );
  304. $wp_plugin_dir = wp_normalize_path( WP_PLUGIN_DIR );
  305. if ( 0 === strpos( $error_file, $wp_plugin_dir ) ) {
  306. $path = str_replace( $wp_plugin_dir . '/', '', $error_file );
  307. $parts = explode( '/', $path );
  308. return array(
  309. 'type' => 'plugin',
  310. 'slug' => $parts[0],
  311. );
  312. }
  313. if ( empty( $wp_theme_directories ) ) {
  314. return false;
  315. }
  316. foreach ( $wp_theme_directories as $theme_directory ) {
  317. $theme_directory = wp_normalize_path( $theme_directory );
  318. if ( 0 === strpos( $error_file, $theme_directory ) ) {
  319. $path = str_replace( $theme_directory . '/', '', $error_file );
  320. $parts = explode( '/', $path );
  321. return array(
  322. 'type' => 'theme',
  323. 'slug' => $parts[0],
  324. );
  325. }
  326. }
  327. return false;
  328. }
  329. /**
  330. * Checks whether the given extension a network activated plugin.
  331. *
  332. * @since 5.2.0
  333. *
  334. * @param array $extension Extension data.
  335. * @return bool True if network plugin, false otherwise.
  336. */
  337. protected function is_network_plugin( $extension ) {
  338. if ( 'plugin' !== $extension['type'] ) {
  339. return false;
  340. }
  341. if ( ! is_multisite() ) {
  342. return false;
  343. }
  344. $network_plugins = wp_get_active_network_plugins();
  345. foreach ( $network_plugins as $plugin ) {
  346. if ( 0 === strpos( $plugin, $extension['slug'] . '/' ) ) {
  347. return true;
  348. }
  349. }
  350. return false;
  351. }
  352. /**
  353. * Stores the given error so that the extension causing it is paused.
  354. *
  355. * @since 5.2.0
  356. *
  357. * @param array $error Error that was triggered.
  358. * @return bool True if the error was stored successfully, false otherwise.
  359. */
  360. protected function store_error( $error ) {
  361. $extension = $this->get_extension_for_error( $error );
  362. if ( ! $extension ) {
  363. return false;
  364. }
  365. switch ( $extension['type'] ) {
  366. case 'plugin':
  367. return wp_paused_plugins()->set( $extension['slug'], $error );
  368. case 'theme':
  369. return wp_paused_themes()->set( $extension['slug'], $error );
  370. default:
  371. return false;
  372. }
  373. }
  374. /**
  375. * Redirects the current request to allow recovering multiple errors in one go.
  376. *
  377. * The redirection will only happen when on a protected endpoint.
  378. *
  379. * It must be ensured that this method is only called when an error actually occurred and will not occur on the
  380. * next request again. Otherwise it will create a redirect loop.
  381. *
  382. * @since 5.2.0
  383. */
  384. protected function redirect_protected() {
  385. // Pluggable is usually loaded after plugins, so we manually include it here for redirection functionality.
  386. if ( ! function_exists( 'wp_safe_redirect' ) ) {
  387. require_once ABSPATH . WPINC . '/pluggable.php';
  388. }
  389. $scheme = is_ssl() ? 'https://' : 'http://';
  390. $url = "{$scheme}{$_SERVER['HTTP_HOST']}{$_SERVER['REQUEST_URI']}";
  391. wp_safe_redirect( $url );
  392. exit;
  393. }
  394. }