| @@ -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 | /** |