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.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-htaccess-manager.php +139 -69 2.11.12.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,12 +246,25 @@
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 *
240 269 * Takes the same write lock as add_block(): until 2.11.0 this
241 270 * read-modify-write ran unlocked, so a removal racing an addition of a
@@ -265,16 +294,27 @@
265 294 // Read current content
266 295 $content = $this->read_file();
267 296
268 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 + }
269 303 return true; // Nothing to remove
270 304 }
271 305
272 306 // Check if block exists
273 307 if ( strpos( $content, $marker_start ) === false ) {
308 + $this->forget_owned_block( $marker_start );
274 309 return true; // Block doesn't exist, nothing to do
275 310 }
276 311
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 +
277 317 // Create backup before modification
278 318 $this->create_backup( $content );
279 319
280 320 // Remove the block
@@ -289,8 +329,9 @@
289 329 }
290 330
291 331 // Write file
292 332 if ( $this->write_file( $new_content ) ) {
333 + $this->forget_owned_block( $marker_start );
293 334 return true;
294 335 }
295 336
296 337 // Write failed, restore backup
@@ -296,8 +337,11 @@
296 337 // Write failed, restore backup
297 338 $this->restore_backup();
298 339 return new WP_Error( 'write_failed', __( 'Failed to write .htaccess', 'vigilante' ) );
299 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();
300 344 $this->release_lock();
301 345 }
302 346 }
303 347
@@ -315,8 +359,47 @@
315 359 return strpos( $content, $marker_start ) !== false;
316 360 }
317 361
318 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 + /**
319 402 * Remove a specific block from content string
320 403 *
321 404 * @param string $content Content to modify.
322 405 * @param string $marker_start Start marker.
@@ -516,25 +599,44 @@
516 599 return false;
517 600 }
518 601 }
519 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 +
520 613 // Write with WP_Filesystem
521 - return $wp_filesystem->put_contents( $this->htaccess_path, $content, FS_CHMOD_FILE );
614 + return $wp_filesystem->put_contents( $this->htaccess_path, $content, $mode );
522 615 }
523 616
524 617 /**
525 - * Create backup of current .htaccess
618 + * Create backup of current .htaccess, for rollback within this operation only
526 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 + *
527 631 * @param string $content Content to backup.
528 632 * @return bool
529 633 */
530 634 private function create_backup( $content ) {
531 - $this->push_history( (string) $content );
532 -
533 - // Store the backup in a private database option instead of a file under
534 - // 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.
535 637 $stored = update_option(
536 - 'vigilante_htaccess_backup',
638 + self::BACKUP_OPTION,
537 639 array(
538 640 'content' => (string) $content,
539 641 'time' => time(),
540 642 ),
@@ -545,46 +647,14 @@
545 647 return ( false !== $stored ) || ( (string) $content === $this->get_backup_content() );
546 648 }
547 649
548 650 /**
549 - * Keep the last few .htaccess versions, newest first.
651 + * Drop the rollback buffer, so no .htaccess content lingers in the options table
550 652 *
551 - * The single-slot backup above is the rollback buffer: it is overwritten by
552 - * the very next write, which is right for its job and useless for anything
553 - * else. Something that only becomes visible days later, such as a header
554 - * that quietly stopped being sent, needs more than one step of history.
555 - *
556 - * Kept in the database with autoload off, never in a file under the web
557 - * root. Bounded on both axes so a large .htaccess cannot inflate the
558 - * options table: oversized files are not stored at all, rather than stored
559 - * truncated, because half an .htaccess is worse than none.
560 - *
561 - * @since 2.10.0
562 - * @param string $content Content being replaced.
653 + * @since 2.11.9
563 654 */
564 - private function push_history( $content ) {
565 - if ( '' === $content || strlen( $content ) > self::HISTORY_MAX_BYTES ) {
566 - return;
567 - }
568 -
569 - $history = get_option( self::HISTORY_OPTION );
570 - $history = is_array( $history ) ? $history : array();
571 -
572 - // Nothing changed, nothing to record.
573 - if ( isset( $history[0]['content'] ) && $history[0]['content'] === $content ) {
574 - return;
575 - }
576 -
577 - array_unshift(
578 - $history,
579 - array(
580 - 'content' => $content,
581 - 'time' => time(),
582 - 'version' => VIGILANTE_VERSION,
583 - )
584 - );
585 -
586 - update_option( self::HISTORY_OPTION, array_slice( $history, 0, self::HISTORY_ENTRIES ), false );
655 + private function clear_backup() {
656 + delete_option( self::BACKUP_OPTION );
587 657 }
588 658
589 659 /**
590 660 * Get the stored .htaccess backup content, or '' if none.
@@ -591,9 +661,9 @@
591 661 *
592 662 * @return string
593 663 */
594 664 private function get_backup_content() {
595 - $backup = get_option( 'vigilante_htaccess_backup' );
665 + $backup = get_option( self::BACKUP_OPTION );
596 666 return ( is_array( $backup ) && isset( $backup['content'] ) ) ? (string) $backup['content'] : '';
597 667 }
598 668
599 669 /**