PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.12
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.12
3.0.1 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 All 89 releases
← All changes | includes/class-htaccess-manager.php +187 -98 2.10.1 → 2.11.12 View file →
@@ -59,16 +59,13 @@
59 59 const LOCK_OPTION = 'vigilante_htaccess_write_lock';
60 60 const LOCK_TIMEOUT = 30;
61 61
62 62 /**
63 - * Rolling history of replaced .htaccess versions, and the one-off snapshot
64 - * taken from a site the 2.9.8 migration had already wiped.
63 + * The single-slot rollback buffer, cleared at the end of every operation.
65 64 *
66 65 * @since 2.10.0
67 66 */
68 - const HISTORY_OPTION = 'vigilante_htaccess_history';
69 - const HISTORY_ENTRIES = 5;
70 - const HISTORY_MAX_BYTES = 262144;
67 + const BACKUP_OPTION = 'vigilante_htaccess_backup';
71 68
72 69 private $known_blocks = array(
73 70 '# BEGIN Vigilante Protection' => '# END Vigilante Protection',
74 71 '# BEGIN Vigilante Security Headers' => '# END Vigilante Security Headers',
@@ -140,12 +137,34 @@
140 137
141 138 try {
142 139 // Read current content
143 140 $original = $this->read_file();
141 +
142 + /*
143 + * read_file() answers '' when there is no file and false when there
144 + * is one PHP cannot read. Until 2.11.8 both became an empty string
145 + * here, so a .htaccess that PHP could write but not read was replaced
146 + * whole by the Vigilant block, and every other rule in it was lost.
147 + */
144 148 if ( false === $original ) {
145 - $original = '';
149 + return new WP_Error( 'read_failed', __( '.htaccess could not be read, so it was left as it is.', 'vigilante' ) );
146 150 }
147 151
152 + /*
153 + * A block that has lost a marker would take the rest of the file with
154 + * it. The other known blocks are asked too: validate_content() below
155 + * refuses any result where one of them is unmatched, and saying why
156 + * here keeps that refusal from reading as a write failure worth
157 + * retrying every hour.
158 + */
159 + $whole = $this->blocks_are_whole( $original, $marker_start, $marker_end );
160 + foreach ( $this->known_blocks as $known_start => $known_end ) {
161 + $whole = $whole && $this->blocks_are_whole( $original, $known_start, $known_end );
162 + }
163 + if ( ! $whole ) {
164 + return new WP_Error( 'block_incomplete', __( 'A Vigilant block in .htaccess is missing one of its markers, so the file was left as it is.', 'vigilante' ) );
165 + }
166 +
148 167 // Create backup before modification
149 168 if ( ! empty( $original ) ) {
150 169 $this->create_backup( $original );
151 170 }
@@ -190,10 +209,20 @@
190 209
191 210 return new WP_Error( 'verify_failed', __( 'The .htaccess was written but did not read back as expected, so the previous content was restored', 'vigilante' ) );
192 211 }
193 212
213 + // Record the block as Vigilant's own. The integrity scan leaves
214 + // out of its hash only the blocks whose fingerprint is recorded,
215 + // so anything else carrying these markers is still checked.
216 + if ( class_exists( 'Vigilante_File_Integrity' ) ) {
217 + Vigilante_File_Integrity::remember_owned_block( '.htaccess', $marker_start, $block );
218 + }
219 +
194 220 return true;
195 221 } finally {
222 + // No .htaccess content, which may hold secrets, is left in the
223 + // options table after the operation. Since 2.11.9.
224 + $this->clear_backup();
196 225 $this->release_lock();
197 226 }
198 227 }
199 228
@@ -199,31 +228,18 @@
199 228
200 229 /**
201 230 * Take the write lock, or fail if another process holds it.
202 231 *
203 - * add_option() is the atomic part: option_name carries a unique index, so
204 - * exactly one caller can create the row. A lock older than the timeout is
205 - * treated as abandoned (a fatal between acquire and release) and taken over,
206 - * otherwise a single crash would freeze every future write.
232 + * Until 2.11.8 this relied on add_option() being atomic, and it is not: it
233 + * runs INSERT ... ON DUPLICATE KEY UPDATE, so two writers arriving together
234 + * both believed they held the lock. See Vigilante_Settings::acquire_option_lock().
235 + * A lock older than the timeout still counts as abandoned and is taken over.
207 236 *
208 237 * @since 2.10.0
209 238 * @return bool
210 239 */
211 240 private function acquire_lock() {
212 - $now = time();
213 - $held = get_option( self::LOCK_OPTION );
214 -
215 - if ( false !== $held && is_numeric( $held ) && ( $now - (int) $held ) < self::LOCK_TIMEOUT ) {
216 - return false;
217 - }
218 -
219 - if ( false !== $held ) {
220 - // Abandoned lock: take it over.
221 - update_option( self::LOCK_OPTION, $now, false );
222 - return true;
223 - }
224 -
225 - return (bool) add_option( self::LOCK_OPTION, $now, '', false );
241 + return Vigilante_Settings::acquire_option_lock( self::LOCK_OPTION, self::LOCK_TIMEOUT );
226 242 }
227 243
228 244 /**
229 245 * Release the write lock.
@@ -230,57 +246,104 @@
230 246 *
231 247 * @since 2.10.0
232 248 */
233 249 private function release_lock() {
234 - delete_option( self::LOCK_OPTION );
250 + Vigilante_Settings::release_option_lock( self::LOCK_OPTION );
235 251 }
236 252
237 253 /**
254 + * Drop the integrity scan's record of a block Vigilant no longer has in the file
255 + *
256 + * @since 2.11.5
257 + *
258 + * @param string $marker_start Start marker of the block.
259 + */
260 + private function forget_owned_block( $marker_start ) {
261 + if ( class_exists( 'Vigilante_File_Integrity' ) ) {
262 + Vigilante_File_Integrity::forget_owned_blocks( '.htaccess', $marker_start );
263 + }
264 + }
265 +
266 + /**
238 267 * Remove a block from .htaccess
239 268 *
269 + * Takes the same write lock as add_block(): until 2.11.0 this
270 + * read-modify-write ran unlocked, so a removal racing an addition of a
271 + * different block could drop the block that had just been written (S5).
272 + *
240 273 * @param string $marker_start Start marker.
241 274 * @param string $marker_end End marker.
275 + * @param bool $automatic True when Vigilant removes the block by itself
276 + * (a mode expiring on cron), false when a person
277 + * asked for it. Same distinction as add_block().
242 278 * @return bool|WP_Error
243 279 */
244 - public function remove_block( $marker_start, $marker_end ) {
245 - if ( ! Vigilante_Settings::can_write_shared_files() ) {
280 + public function remove_block( $marker_start, $marker_end, $automatic = false ) {
281 + $allowed = $automatic
282 + ? Vigilante_Settings::owns_shared_files()
283 + : Vigilante_Settings::can_write_shared_files();
284 +
285 + if ( ! $allowed ) {
246 286 return new WP_Error( 'network_not_owner', Vigilante_Settings::get_shared_files_notice() );
247 287 }
248 288
249 - // Read current content
250 - $content = $this->read_file();
251 -
252 - if ( false === $content || empty( $content ) ) {
253 - return true; // Nothing to remove
289 + if ( ! $this->acquire_lock() ) {
290 + return new WP_Error( 'locked', __( 'Another process is writing .htaccess right now', 'vigilante' ) );
254 291 }
255 292
256 - // Check if block exists
257 - if ( strpos( $content, $marker_start ) === false ) {
258 - return true; // Block doesn't exist, nothing to do
259 - }
293 + try {
294 + // Read current content
295 + $content = $this->read_file();
260 296
261 - // Create backup before modification
262 - $this->create_backup( $content );
297 + if ( false === $content || empty( $content ) ) {
298 + // An unreadable file proves nothing about the block, so its
299 + // record stays. A missing or empty one has no block left.
300 + if ( false !== $content ) {
301 + $this->forget_owned_block( $marker_start );
302 + }
303 + return true; // Nothing to remove
304 + }
263 305
264 - // Remove the block
265 - $new_content = $this->remove_block_from_content( $content, $marker_start, $marker_end );
306 + // Check if block exists
307 + if ( strpos( $content, $marker_start ) === false ) {
308 + $this->forget_owned_block( $marker_start );
309 + return true; // Block doesn't exist, nothing to do
310 + }
266 311
267 - // Validate result - WordPress rules should still be there if they were before
268 - if ( strpos( $content, '# BEGIN WordPress' ) !== false &&
269 - strpos( $new_content, '# BEGIN WordPress' ) === false ) {
270 - // WordPress rules were removed - this is wrong, restore backup
312 + // A block that has lost a marker would take the rest of the file with it.
313 + if ( ! $this->blocks_are_whole( $content, $marker_start, $marker_end ) ) {
314 + return new WP_Error( 'block_incomplete', __( 'A Vigilant block in .htaccess is missing one of its markers, so the file was left as it is.', 'vigilante' ) );
315 + }
316 +
317 + // Create backup before modification
318 + $this->create_backup( $content );
319 +
320 + // Remove the block
321 + $new_content = $this->remove_block_from_content( $content, $marker_start, $marker_end );
322 +
323 + // Validate result - WordPress rules should still be there if they were before
324 + if ( strpos( $content, '# BEGIN WordPress' ) !== false &&
325 + strpos( $new_content, '# BEGIN WordPress' ) === false ) {
326 + // WordPress rules were removed - this is wrong, restore backup
327 + $this->restore_backup();
328 + return new WP_Error( 'wordpress_rules_lost', __( 'Operation would remove WordPress rules, aborted', 'vigilante' ) );
329 + }
330 +
331 + // Write file
332 + if ( $this->write_file( $new_content ) ) {
333 + $this->forget_owned_block( $marker_start );
334 + return true;
335 + }
336 +
337 + // Write failed, restore backup
271 338 $this->restore_backup();
272 - return new WP_Error( 'wordpress_rules_lost', __( 'Operation would remove WordPress rules, aborted', 'vigilante' ) );
339 + return new WP_Error( 'write_failed', __( 'Failed to write .htaccess', 'vigilante' ) );
340 + } finally {
341 + // No .htaccess content, which may hold secrets, is left in the
342 + // options table after the operation. Since 2.11.9.
343 + $this->clear_backup();
344 + $this->release_lock();
273 345 }
274 -
275 - // Write file
276 - if ( $this->write_file( $new_content ) ) {
277 - return true;
278 - }
279 -
280 - // Write failed, restore backup
281 - $this->restore_backup();
282 - return new WP_Error( 'write_failed', __( 'Failed to write .htaccess', 'vigilante' ) );
283 346 }
284 347
285 348 /**
286 349 * Check if a block exists in .htaccess
@@ -296,8 +359,47 @@
296 359 return strpos( $content, $marker_start ) !== false;
297 360 }
298 361
299 362 /**
363 + * Whether no start marker of a block is left without its end
364 + *
365 + * The removal below works line by line and keeps dropping lines from a start
366 + * marker until it meets an end marker, so a start whose end is missing, or a
367 + * second start before the end, takes everything after it. Neither existing
368 + * check catches that: remove_block() only looks for "# BEGIN WordPress",
369 + * which the rules Network Setup hands out do not carry, and the
370 + * validate_content() of add_block() only compares marker pairs, which still
371 + * match once both WordPress markers have been cut away. An end marker with
372 + * no start before it is harmless to the removal, which just drops that
373 + * line, so it does not count against the content.
374 + *
375 + * @since 2.11.6
376 + *
377 + * @param string $content Content to check.
378 + * @param string $marker_start Start marker.
379 + * @param string $marker_end End marker.
380 + * @return bool
381 + */
382 + private function blocks_are_whole( $content, $marker_start, $marker_end ) {
383 + $inside = false;
384 +
385 + foreach ( explode( "\n", $content ) as $line ) {
386 + $line = trim( $line );
387 +
388 + if ( $line === $marker_start ) {
389 + if ( $inside ) {
390 + return false;
391 + }
392 + $inside = true;
393 + } elseif ( $line === $marker_end ) {
394 + $inside = false;
395 + }
396 + }
397 +
398 + return ! $inside;
399 + }
400 +
401 + /**
300 402 * Remove a specific block from content string
301 403 *
302 404 * @param string $content Content to modify.
303 405 * @param string $marker_start Start marker.
@@ -497,25 +599,44 @@
497 599 return false;
498 600 }
499 601 }
500 602
603 + /*
604 + * Keep the permissions the file already has. put_contents() always sets
605 + * a mode, and FS_CHMOD_FILE is "permissions of index.php | 0644", so
606 + * until 2.11.6 every write left a .htaccess kept at 0640 at 0644 or
607 + * wider. A mode that cannot be read falls back to the old one rather
608 + * than to 0, which would lock the server out of the file.
609 + */
610 + $perms = file_exists( $this->htaccess_path ) ? fileperms( $this->htaccess_path ) : false;
611 + $mode = ( false !== $perms && ( $perms & 0777 ) ) ? ( $perms & 0777 ) : FS_CHMOD_FILE;
612 +
501 613 // Write with WP_Filesystem
502 - return $wp_filesystem->put_contents( $this->htaccess_path, $content, FS_CHMOD_FILE );
614 + return $wp_filesystem->put_contents( $this->htaccess_path, $content, $mode );
503 615 }
504 616
505 617 /**
506 - * Create backup of current .htaccess
618 + * Create backup of current .htaccess, for rollback within this operation only
507 619 *
620 + * A .htaccess can carry secrets (SetEnv credentials, an Authorization
621 + * header, a php_value with a key), so this rollback buffer is a live copy of
622 + * the file and is cleared at the end of every add_block/remove_block, in the
623 + * finally, rather than left sitting in the options table. Until 2.11.9 it
624 + * persisted between operations and a rolling five-version history kept the
625 + * raw content indefinitely, so anyone who read the database or a backup of
626 + * it recovered those secrets without filesystem access. Reported by the
627 + * wp.org automated review of 2.11.8. The history is gone; the buffer holds
628 + * the real content because restoring a redacted one would write the marker
629 + * into the live file, and lives only for the length of the write.
630 + *
508 631 * @param string $content Content to backup.
509 632 * @return bool
510 633 */
511 634 private function create_backup( $content ) {
512 - $this->push_history( (string) $content );
513 -
514 - // Store the backup in a private database option instead of a file under
515 - // the web root, so it can never be served over HTTP.
635 + // A private option, never a file under the web root, and dropped again
636 + // by clear_backup() in the finally of the operation that created it.
516 637 $stored = update_option(
517 - 'vigilante_htaccess_backup',
638 + self::BACKUP_OPTION,
518 639 array(
519 640 'content' => (string) $content,
520 641 'time' => time(),
521 642 ),
@@ -526,46 +647,14 @@
526 647 return ( false !== $stored ) || ( (string) $content === $this->get_backup_content() );
527 648 }
528 649
529 650 /**
530 - * Keep the last few .htaccess versions, newest first.
651 + * Drop the rollback buffer, so no .htaccess content lingers in the options table
531 652 *
532 - * The single-slot backup above is the rollback buffer: it is overwritten by
533 - * the very next write, which is right for its job and useless for anything
534 - * else. Something that only becomes visible days later, such as a header
535 - * that quietly stopped being sent, needs more than one step of history.
536 - *
537 - * Kept in the database with autoload off, never in a file under the web
538 - * root. Bounded on both axes so a large .htaccess cannot inflate the
539 - * options table: oversized files are not stored at all, rather than stored
540 - * truncated, because half an .htaccess is worse than none.
541 - *
542 - * @since 2.10.0
543 - * @param string $content Content being replaced.
653 + * @since 2.11.9
544 654 */
545 - private function push_history( $content ) {
546 - if ( '' === $content || strlen( $content ) > self::HISTORY_MAX_BYTES ) {
547 - return;
548 - }
549 -
550 - $history = get_option( self::HISTORY_OPTION );
551 - $history = is_array( $history ) ? $history : array();
552 -
553 - // Nothing changed, nothing to record.
554 - if ( isset( $history[0]['content'] ) && $history[0]['content'] === $content ) {
555 - return;
556 - }
557 -
558 - array_unshift(
559 - $history,
560 - array(
561 - 'content' => $content,
562 - 'time' => time(),
563 - 'version' => VIGILANTE_VERSION,
564 - )
565 - );
566 -
567 - update_option( self::HISTORY_OPTION, array_slice( $history, 0, self::HISTORY_ENTRIES ), false );
655 + private function clear_backup() {
656 + delete_option( self::BACKUP_OPTION );
568 657 }
569 658
570 659 /**
571 660 * Get the stored .htaccess backup content, or '' if none.
@@ -572,9 +661,9 @@
572 661 *
573 662 * @return string
574 663 */
575 664 private function get_backup_content() {
576 - $backup = get_option( 'vigilante_htaccess_backup' );
665 + $backup = get_option( self::BACKUP_OPTION );
577 666 return ( is_array( $backup ) && isset( $backup['content'] ) ) ? (string) $backup['content'] : '';
578 667 }
579 668
580 669 /**