| @@ -53,9 +53,14 @@ | ||
| 53 | 53 | * Apply security headers to .htaccess |
| 54 | 54 | * |
| 55 | 55 | * @return bool|WP_Error |
| 56 | 56 | */ |
| 57 | - public function apply_rules() { | |
| 57 | + /** | |
| 58 | + * @param bool $automatic True when Vigilant is refreshing the block by | |
| 59 | + * itself rather than because someone pressed Save. | |
| 60 | + * See Vigilante_Htaccess_Manager::add_block(). | |
| 61 | + */ | |
| 62 | + public function apply_rules( $automatic = false ) { | |
| 58 | 63 | require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php'; |
| 59 | 64 | |
| 60 | 65 | $manager = Vigilante_Htaccess_Manager::get_instance(); |
| 61 | 66 | |
| @@ -68,9 +73,9 @@ | ||
| 68 | 73 | } |
| 69 | 74 | |
| 70 | 75 | $rules = $this->generate_rules_content(); |
| 71 | 76 | |
| 72 | - $result = $manager->add_block( self::MARKER_START, self::MARKER_END, $rules, 'top' ); | |
| 77 | + $result = $manager->add_block( self::MARKER_START, self::MARKER_END, $rules, 'top', $automatic ); | |
| 73 | 78 | |
| 74 | 79 | if ( true === $result ) { |
| 75 | 80 | /** This action is documented in class-wpconfig-security.php */ |
| 76 | 81 | do_action( 'vigilante_critical_file_written', '.htaccess' ); |
| @@ -304,8 +309,28 @@ | ||
| 304 | 309 | return str_replace( array( '"', "\r", "\n" ), '', (string) $value ); |
| 305 | 310 | } |
| 306 | 311 | |
| 307 | 312 | /** |
| 313 | + * Whether the site address is an https:// one | |
| 314 | + * | |
| 315 | + * Read from the stored address and from nothing else. is_ssl() is false | |
| 316 | + * behind a proxy that terminates TLS and says nothing in a cron run or from | |
| 317 | + * WP-CLI, and home_url() takes its scheme from is_ssl() when the request is | |
| 318 | + * secure (wp-includes/link-template.php:3471-3477 in 7.1.2), so the same site | |
| 319 | + * would write different rules from an https request and from a scheduled | |
| 320 | + * task. Vigilante_Htaccess_Recovery and File | |
| 321 | + * Integrity compare the file with what this class would write now, so the | |
| 322 | + * answer has to be the same wherever it is asked. | |
| 323 | + * | |
| 324 | + * @since 3.0.2 | |
| 325 | + * | |
| 326 | + * @return bool | |
| 327 | + */ | |
| 328 | + private function site_address_is_https() { | |
| 329 | + return 0 === strpos( (string) get_option( 'home' ), 'https://' ); | |
| 330 | + } | |
| 331 | + | |
| 332 | + /** | |
| 308 | 333 | * Generate rules content (without markers) |
| 309 | 334 | * |
| 310 | 335 | * Public since 2.10.0: it is also "what this configuration would write right |
| 311 | 336 | * now", which is what Vigilante_Htaccess_Recovery compares the file against. |
| @@ -409,9 +434,25 @@ | ||
| 409 | 434 | } |
| 410 | 435 | } |
| 411 | 436 | |
| 412 | 437 | if ( ! empty( $csp['directives'] ) && is_array( $csp['directives'] ) ) { |
| 438 | + $site_is_https = $this->site_address_is_https(); | |
| 439 | + | |
| 413 | 440 | foreach ( $csp['directives'] as $directive => $value ) { |
| 441 | + /* | |
| 442 | + * upgrade-insecure-requests tells the browser to ask for every | |
| 443 | + * http:// resource of the page over https. On a site whose | |
| 444 | + * address is http:// nothing answers there, so every | |
| 445 | + * stylesheet, script and image stops loading, and the factory | |
| 446 | + * policy carries it. It is written only when the site address | |
| 447 | + * is https://. The setting itself is left as it is, so the | |
| 448 | + * next rewrite of the block after the address changes writes | |
| 449 | + * it. | |
| 450 | + */ | |
| 451 | + if ( 'upgrade-insecure-requests' === strtolower( (string) $directive ) && ! $site_is_https ) { | |
| 452 | + continue; | |
| 453 | + } | |
| 454 | + | |
| 414 | 455 | // Override frame-ancestors with synchronized value if X-Frame-Options is set |
| 415 | 456 | if ( 'frame-ancestors' === $directive && ! empty( $frame_ancestors_value ) ) { |
| 416 | 457 | $directives[] = $directive . ' ' . $frame_ancestors_value; |
| 417 | 458 | } elseif ( true === $value ) { |
| @@ -510,114 +551,46 @@ | ||
| 510 | 551 | return implode( "\n", $rules ); |
| 511 | 552 | } |
| 512 | 553 | |
| 513 | 554 | /** |
| 514 | - * Get security grade based on enabled headers | |
| 555 | + * Warnings about the Content-Security-Policy as it is configured | |
| 515 | 556 | * |
| 516 | - * @return array | |
| 557 | + * They are about what is saved in the tab, not about what reaches the | |
| 558 | + * browser: that is measured by the checks of Security Check, which is | |
| 559 | + * also what the Test Headers button shows. Until 3.0.4 that button graded | |
| 560 | + * these settings and listed them as enabled headers without asking the | |
| 561 | + * site for anything. | |
| 562 | + * | |
| 563 | + * @since 3.0.5 | |
| 564 | + * | |
| 565 | + * @return string[] | |
| 517 | 566 | */ |
| 518 | - public function get_security_grade() { | |
| 519 | - $score = 0; | |
| 520 | - $enabled = array(); | |
| 521 | - $missing = array(); | |
| 567 | + public function get_csp_warnings() { | |
| 522 | 568 | $warnings = array(); |
| 523 | 569 | |
| 524 | - // X-Frame-Options (15 points) | |
| 525 | - if ( ! empty( $this->options['x_frame_options'] ) ) { | |
| 526 | - $score += 15; | |
| 527 | - $enabled[] = 'X-Frame-Options: ' . $this->options['x_frame_options']; | |
| 528 | - } else { | |
| 529 | - $missing[] = 'X-Frame-Options'; | |
| 570 | + if ( empty( $this->options['csp']['enabled'] ) ) { | |
| 571 | + return $warnings; | |
| 530 | 572 | } |
| 531 | 573 | |
| 532 | - // X-Content-Type-Options (15 points) | |
| 533 | - if ( ! empty( $this->options['x_content_type_options'] ) ) { | |
| 534 | - $score += 15; | |
| 535 | - $enabled[] = 'X-Content-Type-Options: nosniff'; | |
| 536 | - } else { | |
| 537 | - $missing[] = 'X-Content-Type-Options'; | |
| 574 | + if ( ! empty( $this->options['csp']['report_only'] ) ) { | |
| 575 | + $warnings[] = __( 'CSP is in report-only mode: the browser reports what the policy would block, and blocks nothing.', 'vigilante' ); | |
| 576 | + return $warnings; | |
| 538 | 577 | } |
| 539 | 578 | |
| 540 | - // HSTS (20 points) | |
| 541 | - if ( ! empty( $this->options['hsts']['enabled'] ) ) { | |
| 542 | - $hsts = $this->options['hsts']; | |
| 543 | - if ( $hsts['max_age'] >= 31536000 ) { | |
| 544 | - $score += 20; | |
| 545 | - } else { | |
| 546 | - $score += 10; | |
| 547 | - $warnings[] = 'HSTS max-age should be at least 1 year (31536000 seconds)'; | |
| 548 | - } | |
| 549 | - $enabled[] = 'Strict-Transport-Security'; | |
| 550 | - } else { | |
| 551 | - $missing[] = 'Strict-Transport-Security (HSTS)'; | |
| 579 | + if ( $this->is_csp_restrictive() ) { | |
| 580 | + $warnings[] = __( 'Restrictive CSP detected. Admin area is automatically excluded to prevent breaking the dashboard and block editor.', 'vigilante' ); | |
| 552 | 581 | } |
| 553 | 582 | |
| 554 | - // CSP (30 points; absorbs the 10 points freed by retiring the deprecated X-XSS-Protection) | |
| 555 | - if ( ! empty( $this->options['csp']['enabled'] ) ) { | |
| 556 | - if ( empty( $this->options['csp']['report_only'] ) ) { | |
| 557 | - $score += 30; | |
| 558 | - $enabled[] = 'Content-Security-Policy'; | |
| 559 | - | |
| 560 | - // Add warning if CSP is restrictive | |
| 561 | - if ( $this->is_csp_restrictive() ) { | |
| 562 | - $warnings[] = __( 'Restrictive CSP detected. Admin area is automatically excluded to prevent breaking the dashboard and block editor.', 'vigilante' ); | |
| 563 | - } | |
| 564 | - | |
| 565 | - // Check for specific compatibility issues | |
| 566 | - $csp_issues = $this->get_csp_compatibility_issues(); | |
| 567 | - foreach ( $csp_issues as $issue ) { | |
| 568 | - if ( 'high' === $issue['severity'] ) { | |
| 569 | - $warnings[] = sprintf( | |
| 570 | - /* translators: 1: CSP directive name, 2: Issue description */ | |
| 571 | - __( 'CSP %1$s: %2$s', 'vigilante' ), | |
| 572 | - $issue['directive'], | |
| 573 | - $issue['issue'] | |
| 574 | - ); | |
| 575 | - } | |
| 576 | - } | |
| 577 | - } else { | |
| 578 | - $score += 15; | |
| 579 | - $enabled[] = 'Content-Security-Policy-Report-Only'; | |
| 580 | - $warnings[] = 'CSP is in report-only mode (recommended for testing)'; | |
| 583 | + foreach ( $this->get_csp_compatibility_issues() as $issue ) { | |
| 584 | + if ( 'high' === $issue['severity'] ) { | |
| 585 | + $warnings[] = sprintf( | |
| 586 | + /* translators: 1: CSP directive name, 2: Issue description */ | |
| 587 | + __( 'CSP %1$s: %2$s', 'vigilante' ), | |
| 588 | + $issue['directive'], | |
| 589 | + $issue['issue'] | |
| 590 | + ); | |
| 581 | 591 | } |
| 582 | - } else { | |
| 583 | - $missing[] = 'Content-Security-Policy'; | |
| 584 | 592 | } |
| 585 | 593 | |
| 586 | - // Referrer-Policy (10 points) | |
| 587 | - if ( ! empty( $this->options['referrer_policy'] ) ) { | |
| 588 | - $score += 10; | |
| 589 | - $enabled[] = 'Referrer-Policy: ' . $this->options['referrer_policy']; | |
| 590 | - } else { | |
| 591 | - $missing[] = 'Referrer-Policy'; | |
| 592 | - } | |
| 593 | - | |
| 594 | - // Permissions-Policy (10 points) | |
| 595 | - if ( ! empty( $this->options['permissions_policy']['enabled'] ) ) { | |
| 596 | - $score += 10; | |
| 597 | - $enabled[] = 'Permissions-Policy'; | |
| 598 | - } else { | |
| 599 | - $missing[] = 'Permissions-Policy'; | |
| 600 | - } | |
| 601 | - | |
| 602 | - // Calculate grade | |
| 603 | - if ( $score >= 90 ) { | |
| 604 | - $grade = 'A'; | |
| 605 | - } elseif ( $score >= 80 ) { | |
| 606 | - $grade = 'B'; | |
| 607 | - } elseif ( $score >= 70 ) { | |
| 608 | - $grade = 'C'; | |
| 609 | - } elseif ( $score >= 60 ) { | |
| 610 | - $grade = 'D'; | |
| 611 | - } else { | |
| 612 | - $grade = 'F'; | |
| 613 | - } | |
| 614 | - | |
| 615 | - return array( | |
| 616 | - 'grade' => $grade, | |
| 617 | - 'score' => $score, | |
| 618 | - 'headers' => $enabled, | |
| 619 | - 'missing' => $missing, | |
| 620 | - 'warnings' => $warnings, | |
| 621 | - ); | |
| 594 | + return $warnings; | |
| 622 | 595 | } |
| 623 | 596 | } |