PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.0
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.0
3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 All 88 releases
← All changes | includes/class-backup-manager.php +127 -275 2.9.93.0.0 View file →
@@ -1,11 +1,11 @@
1 1 <?php
2 2 /**
3 3 * Backup Manager Class
4 4 *
5 - * Handles backup and restoration of critical files. Backups are stored in
6 - * private database options (autoload off), never as files under the web root,
7 - * so a copy of wp-config.php or .htaccess can never be served over HTTP.
5 + * Builds the downloadable archive of the configuration files, and cleans up
6 + * the copies of those files that earlier versions kept: on disk under the web
7 + * root until 2.7.0, and in the options table until 2.11.6.
8 8 *
9 9 * @package Vigilante
10 10 */
11 11
@@ -16,29 +16,54 @@
16 16
17 17 /**
18 18 * Class Vigilante_Backup_Manager
19 19 *
20 - * Manages file backups for security modifications.
20 + * Configuration file archive and cleanup of stored copies.
21 21 */
22 22 class Vigilante_Backup_Manager {
23 23
24 24 /**
25 - * Legacy on-disk backup directory (kept only to clean it up on upgrade).
25 + * Option recording that the stored copies are gone
26 26 *
27 - * @var string
27 + * 1 once this site is clean, 2 once the walk over the network has finished
28 + * as well, so the site stops asking the network. Autoloaded, because it is
29 + * read on every request.
30 + *
31 + * @since 2.11.6
28 32 */
29 - private $backup_dir;
33 + const COPIES_PURGED_OPTION = 'vigilante_config_copies_purged';
30 34
31 35 /**
32 - * Maximum number of backups to keep
36 + * Network option holding the last site the walk cleaned, or 'done'
33 37 *
34 - * @var int
38 + * @since 2.11.6
35 39 */
36 - private $max_backups = 5;
40 + const COPIES_SWEEP_OPTION = 'vigilante_config_copies_sweep';
37 41
38 42 /**
39 - * Files to backup
43 + * Sites the walk cleans per request
40 44 *
45 + * @since 2.11.6
46 + */
47 + const COPIES_SWEEP_BATCH = 50;
48 +
49 + /**
50 + * Lock that keeps two requests from walking the network at once
51 + *
52 + * @since 2.11.8
53 + */
54 + const COPIES_SWEEP_LOCK = 'vigilante_config_copies_sweep_lock';
55 +
56 + /**
57 + * Legacy on-disk backup directory (kept only to clean it up on upgrade).
58 + *
59 + * @var string
60 + */
61 + private $backup_dir;
62 +
63 + /**
64 + * Configuration files the archive carries
65 + *
41 66 * @var array
42 67 */
43 68 private $backup_files = array();
44 69
@@ -70,221 +95,131 @@
70 95 );
71 96 }
72 97
73 98 /**
74 - * Create backups of all important files
99 + * Delete the copies of the configuration files kept in this site's options
75 100 *
76 - * The content is stored in the database, never copied to a file under the
77 - * web root.
101 + * Until 2.11.6 activating Vigilant copied wp-config.php, .htaccess and
102 + * robots.txt into vigilante_backup_info_<date>, up to five of them, and
103 + * writing the constants block kept one more copy of wp-config.php in
104 + * vigilante_wpconfig_backup. Nothing read them back: the method that
105 + * restored the files had no caller. wp-config.php carries the database
106 + * password and the authentication keys and salts, so every copy put them in
107 + * the options table, within reach of anyone who can read the database or a
108 + * dump of it. 2.7.0 moved the copies there from files under the web root,
109 + * which changed where they lived and not whether they should exist.
78 110 *
79 - * @return true|WP_Error True on success, WP_Error on failure.
111 + * @since 2.11.6
80 112 */
81 - public function create_backups() {
82 - $timestamp = gmdate( 'Y-m-d_H-i-s' );
83 - $backup_info = array();
84 - $errors = array();
113 + public static function purge_stored_copies() {
114 + global $wpdb;
85 115
86 - foreach ( $this->backup_files as $key => $file ) {
87 - if ( file_exists( $file['source'] ) ) {
88 - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_get_contents -- reading a known local config file to store it in the DB, not a filesystem op on user input.
89 - $content = file_get_contents( $file['source'] );
116 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- one-off cleanup of our own dated option names, which the options API cannot list.
117 + $names = $wpdb->get_col( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE 'vigilante\\_backup\\_info\\_%'" );
90 118
91 - if ( false !== $content ) {
92 - $backup_info[ $key ] = array(
93 - 'content' => $content,
94 - 'hash' => md5( $content ),
95 - 'size' => strlen( $content ),
96 - 'exists' => true,
97 - 'time' => time(),
98 - );
99 - } else {
100 - $errors[] = sprintf(
101 - /* translators: %s: File name */
102 - __( 'Failed to backup %s', 'vigilante' ),
103 - basename( $file['source'] )
104 - );
105 - }
106 - } else {
107 - // Mark as non-existent (important for restoration).
108 - $backup_info[ $key ] = array(
109 - 'content' => '',
110 - 'exists' => false,
111 - 'time' => time(),
112 - );
113 - }
119 + foreach ( (array) $names as $name ) {
120 + delete_option( $name );
114 121 }
115 122
116 - if ( ! empty( $errors ) ) {
117 - return new WP_Error( 'backup_partial', implode( ', ', $errors ) );
118 - }
119 -
120 - // Store metadata + content in non-autoloaded options (may be large and
121 - // is only needed on demand).
122 - $backup_info['timestamp'] = $timestamp;
123 - update_option( 'vigilante_backup_timestamp', $timestamp, false );
124 - update_option( 'vigilante_backup_info_' . $timestamp, $backup_info, false );
125 -
126 - $this->cleanup_old_backups();
127 -
128 - return true;
123 + delete_option( 'vigilante_backup_timestamp' );
124 + delete_option( 'vigilante_wpconfig_backup' );
129 125 }
130 126
131 127 /**
132 - * Restore files from backup
128 + * Run purge_stored_copies() once on this site, and once over the network
133 129 *
134 - * @param string $timestamp Optional specific timestamp to restore.
135 - * @return true|WP_Error
130 + * The walk exists for the sites where Vigilant no longer runs: a copy made
131 + * by a per-site activation stays in that site's options after the plugin is
132 + * deactivated there, and nothing on that site would ever clean it. It
133 + * advances one batch of sites per request, from whichever site runs
134 + * Vigilant, remembers the last site it cleaned, and only visits this
135 + * network.
136 + *
137 + * @since 2.11.6
136 138 */
137 - public function restore_backups( $timestamp = '' ) {
138 - if ( empty( $timestamp ) ) {
139 - $timestamp = get_option( 'vigilante_backup_timestamp' );
139 + public static function maybe_purge_stored_copies() {
140 + $state = (int) get_option( self::COPIES_PURGED_OPTION, 0 );
141 +
142 + if ( $state >= 2 ) {
143 + return;
140 144 }
141 145
142 - if ( empty( $timestamp ) ) {
143 - return new WP_Error(
144 - 'no_backup',
145 - __( 'No backup found to restore.', 'vigilante' )
146 - );
146 + if ( $state < 1 ) {
147 + self::purge_stored_copies();
148 + update_option( self::COPIES_PURGED_OPTION, 1, true );
147 149 }
148 150
149 - $backup_info = get_option( 'vigilante_backup_info_' . $timestamp );
150 -
151 - if ( empty( $backup_info ) ) {
152 - return new WP_Error(
153 - 'backup_info_missing',
154 - __( 'Backup information not found.', 'vigilante' )
155 - );
151 + if ( ! is_multisite() ) {
152 + update_option( self::COPIES_PURGED_OPTION, 2, true );
153 + return;
156 154 }
157 155
158 - $errors = array();
156 + if ( 'done' !== get_site_option( self::COPIES_SWEEP_OPTION, 0 ) ) {
157 + /*
158 + * One request walks at a time. Right after an update every request
159 + * gets here, and until 2.11.8 each of them repeated the same batch of
160 + * sites. The lock lives in the options table of the main site, which
161 + * every site of the network reaches the same way.
162 + */
163 + switch_to_blog( get_main_site_id() );
164 + $locked = Vigilante_Settings::acquire_option_lock( self::COPIES_SWEEP_LOCK, MINUTE_IN_SECONDS );
165 + restore_current_blog();
159 166
160 - foreach ( $this->backup_files as $key => $file ) {
161 - if ( ! isset( $backup_info[ $key ] ) ) {
162 - continue;
167 + if ( ! $locked ) {
168 + return;
163 169 }
164 170
165 - $info = $backup_info[ $key ];
166 -
167 - // If the file did not exist originally, delete it.
168 - if ( isset( $info['exists'] ) && false === $info['exists'] ) {
169 - if ( file_exists( $file['source'] ) ) {
170 - wp_delete_file( $file['source'] );
171 - }
172 - continue;
171 + try {
172 + self::sweep_next_batch();
173 + } finally {
174 + switch_to_blog( get_main_site_id() );
175 + Vigilante_Settings::release_option_lock( self::COPIES_SWEEP_LOCK );
176 + restore_current_blog();
173 177 }
174 -
175 - if ( ! isset( $info['content'] ) || '' === $info['content'] ) {
176 - continue;
177 - }
178 -
179 - // Verify integrity against the stored hash.
180 - if ( isset( $info['hash'] ) && md5( $info['content'] ) !== $info['hash'] ) {
181 - $errors[] = sprintf(
182 - /* translators: %s: File name */
183 - __( 'Backup integrity check failed for %s', 'vigilante' ),
184 - $file['name']
185 - );
186 - continue;
187 - }
188 -
189 - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- restoring a known local config file from the DB backup.
190 - if ( false === file_put_contents( $file['source'], $info['content'] ) ) {
191 - $errors[] = sprintf(
192 - /* translators: %s: File name */
193 - __( 'Failed to restore %s', 'vigilante' ),
194 - basename( $file['source'] )
195 - );
196 - }
197 178 }
198 179
199 - if ( ! empty( $errors ) ) {
200 - return new WP_Error( 'restore_partial', implode( ', ', $errors ) );
180 + if ( 'done' === get_site_option( self::COPIES_SWEEP_OPTION, 0 ) ) {
181 + update_option( self::COPIES_PURGED_OPTION, 2, true );
201 182 }
202 -
203 - return true;
204 183 }
205 184
206 185 /**
207 - * Cleanup old backups keeping only the most recent
186 + * Clean the next batch of sites of the network, with the walk lock held
187 + *
188 + * @since 2.11.8
208 189 */
209 - private function cleanup_old_backups() {
210 - $settings = new Vigilante_Settings();
211 - $backup_settings = $settings->get_section( 'backup' );
212 - // The default is stored as keep_backups. Until 2.9.9 this read max_backups,
213 - // a key nothing ever wrote, so the configured value was ignored and the
214 - // hardcoded 5 always won.
215 - $this->max_backups = isset( $backup_settings['keep_backups'] ) ? absint( $backup_settings['keep_backups'] ) : 5;
216 -
190 + private static function sweep_next_batch() {
217 191 global $wpdb;
218 192
219 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- one-off maintenance scan of our own option names.
220 - $backup_options = $wpdb->get_col(
221 - "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE 'vigilante_backup_info_%' ORDER BY option_name DESC"
222 - );
193 + // Read again inside the lock: the request that held it before may have
194 + // moved the walk on.
195 + // The core caches network options under "$network_id:$option", and absent
196 + // ones in "$network_id:notoptions" (wp-includes/option.php:2091 and :2065).
197 + wp_cache_delete( get_current_network_id() . ':' . self::COPIES_SWEEP_OPTION, 'site-options' );
198 + wp_cache_delete( get_current_network_id() . ':notoptions', 'site-options' );
199 + $sweep = get_site_option( self::COPIES_SWEEP_OPTION, 0 );
223 200
224 - if ( count( $backup_options ) > $this->max_backups ) {
225 - $to_delete = array_slice( $backup_options, $this->max_backups );
226 -
227 - foreach ( $to_delete as $option_name ) {
228 - $timestamp = str_replace( 'vigilante_backup_info_', '', $option_name );
229 - $this->delete_backup( $timestamp );
230 - }
201 + if ( 'done' === $sweep ) {
202 + return;
231 203 }
232 - }
233 204
234 - /**
235 - * Delete a specific backup
236 - *
237 - * @param string $timestamp Backup timestamp.
238 - * @return bool
239 - */
240 - public function delete_backup( $timestamp ) {
241 - delete_option( 'vigilante_backup_info_' . $timestamp );
242 -
243 - $current_timestamp = get_option( 'vigilante_backup_timestamp' );
244 - if ( $current_timestamp === $timestamp ) {
245 - delete_option( 'vigilante_backup_timestamp' );
246 - }
247 -
248 - return true;
249 - }
250 -
251 - /**
252 - * Get list of available backups
253 - *
254 - * @return array
255 - */
256 - public function get_available_backups() {
257 - global $wpdb;
258 -
259 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- listing our own option names.
260 - $backup_options = $wpdb->get_col(
261 - "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE 'vigilante_backup_info_%' ORDER BY option_name DESC"
205 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- the walk needs the sites after the last one it cleaned, in id order, and get_sites() cannot ask for ids greater than a value.
206 + $site_ids = $wpdb->get_col(
207 + $wpdb->prepare(
208 + "SELECT blog_id FROM {$wpdb->blogs} WHERE site_id = %d AND blog_id > %d ORDER BY blog_id ASC LIMIT %d",
209 + get_current_network_id(),
210 + (int) $sweep,
211 + self::COPIES_SWEEP_BATCH
212 + )
262 213 );
263 214
264 - $backups = array();
265 -
266 - foreach ( $backup_options as $option_name ) {
267 - $timestamp = str_replace( 'vigilante_backup_info_', '', $option_name );
268 - $info = get_option( $option_name );
269 -
270 - if ( ! empty( $info ) ) {
271 - $backups[] = array(
272 - 'timestamp' => $timestamp,
273 - 'date' => date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), strtotime( str_replace( '_', ' ', $timestamp ) ) ),
274 - 'files' => count(
275 - array_filter(
276 - $info,
277 - function ( $item ) {
278 - return is_array( $item ) && ! empty( $item['exists'] );
279 - }
280 - )
281 - ),
282 - );
283 - }
215 + foreach ( $site_ids as $site_id ) {
216 + switch_to_blog( (int) $site_id );
217 + self::purge_stored_copies();
218 + restore_current_blog();
284 219 }
285 220
286 - return $backups;
221 + update_site_option( self::COPIES_SWEEP_OPTION, count( $site_ids ) < self::COPIES_SWEEP_BATCH ? 'done' : (int) end( $site_ids ) );
287 222 }
288 223
289 224 /**
290 225 * Get the legacy on-disk backup directory path.
@@ -298,27 +233,8 @@
298 233 return $this->backup_dir;
299 234 }
300 235
301 236 /**
302 - * Check if backups exist
303 - *
304 - * @return bool
305 - */
306 - public function has_backups() {
307 - $timestamp = get_option( 'vigilante_backup_timestamp' );
308 - return ! empty( $timestamp );
309 - }
310 -
311 - /**
312 - * Get last backup timestamp
313 - *
314 - * @return string|false
315 - */
316 - public function get_last_backup_timestamp() {
317 - return get_option( 'vigilante_backup_timestamp' );
318 - }
319 -
320 - /**
321 237 * Build a ZIP with the current config files and stream it to the browser.
322 238 *
323 239 * Used by the "Create Backup" tool: instead of leaving files under the web
324 240 * root, it hands the admin a downloadable archive of wp-config.php and
@@ -326,8 +242,14 @@
326 242 *
327 243 * @return void|WP_Error WP_Error on failure; on success it streams and exits.
328 244 */
329 245 public function stream_files_zip() {
246 + // Second line of defence: the archive contains network-shared files, so
247 + // no future caller can hand them to a subsite administrator by mistake.
248 + if ( ! Vigilante_Settings::can_write_shared_files() ) {
249 + return new WP_Error( 'shared_files_denied', Vigilante_Settings::get_shared_files_notice() );
250 + }
251 +
330 252 if ( ! class_exists( 'ZipArchive' ) ) {
331 253 return new WP_Error( 'zip_unavailable', __( 'ZipArchive extension is not available on this server.', 'vigilante' ) );
332 254 }
333 255
@@ -375,78 +297,8 @@
375 297 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- streaming a freshly built archive to the browser.
376 298 readfile( $zip_path );
377 299 wp_delete_file( $zip_path );
378 300 exit;
379 - }
380 -
381 - /**
382 - * Verify backup integrity
383 - *
384 - * @param string $timestamp Backup timestamp.
385 - * @return array Verification results.
386 - */
387 - public function verify_backup( $timestamp ) {
388 - $backup_info = get_option( 'vigilante_backup_info_' . $timestamp );
389 -
390 - if ( empty( $backup_info ) ) {
391 - return array(
392 - 'valid' => false,
393 - 'errors' => array( __( 'Backup information not found.', 'vigilante' ) ),
394 - );
395 - }
396 -
397 - $results = array(
398 - 'valid' => true,
399 - 'errors' => array(),
400 - 'files' => array(),
401 - );
402 -
403 - foreach ( $this->backup_files as $key => $file ) {
404 - if ( ! isset( $backup_info[ $key ] ) ) {
405 - continue;
406 - }
407 -
408 - $info = $backup_info[ $key ];
409 -
410 - // Skip non-existent files.
411 - if ( isset( $info['exists'] ) && false === $info['exists'] ) {
412 - $results['files'][ $key ] = array(
413 - 'status' => 'skipped',
414 - 'reason' => __( 'File did not exist', 'vigilante' ),
415 - );
416 - continue;
417 - }
418 -
419 - if ( ! isset( $info['content'] ) ) {
420 - $results['valid'] = false;
421 - $results['errors'][] = sprintf(
422 - /* translators: %s: File name */
423 - __( 'Backup content missing: %s', 'vigilante' ),
424 - $file['name']
425 - );
426 - $results['files'][ $key ] = array( 'status' => 'missing' );
427 - continue;
428 - }
429 -
430 - // Verify hash.
431 - if ( isset( $info['hash'] ) && md5( $info['content'] ) !== $info['hash'] ) {
432 - $results['valid'] = false;
433 - $results['errors'][] = sprintf(
434 - /* translators: %s: File name */
435 - __( 'Backup corrupted: %s', 'vigilante' ),
436 - $file['name']
437 - );
438 - $results['files'][ $key ] = array( 'status' => 'corrupted' );
439 - continue;
440 - }
441 -
442 - $results['files'][ $key ] = array(
443 - 'status' => 'valid',
444 - 'size' => isset( $info['size'] ) ? (int) $info['size'] : strlen( $info['content'] ),
445 - );
446 - }
447 -
448 - return $results;
449 301 }
450 302
451 303 /**
452 304 * Remove backup files written under the web root by versions before 2.7.0.