settings = $settings;
$this->options = $settings->get_section( 'security_headers' );
}
/**
* Apply security headers to .htaccess
*
* @return bool|WP_Error
*/
public function apply_rules() {
require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
$manager = Vigilante_Htaccess_Manager::get_instance();
if ( ! $manager->is_apache() ) {
return new WP_Error( 'not_apache', __( 'Server is not Apache/LiteSpeed', 'vigilante' ) );
}
if ( ! $manager->is_writable() ) {
return new WP_Error( 'not_writable', __( '.htaccess is not writable', 'vigilante' ) );
}
$rules = $this->generate_rules_content();
$result = $manager->add_block( self::MARKER_START, self::MARKER_END, $rules, 'top' );
if ( true === $result ) {
/** This action is documented in class-wpconfig-security.php */
do_action( 'vigilante_critical_file_written', '.htaccess' );
}
return $result;
}
/**
* Remove security headers from .htaccess
*
* @return bool|WP_Error
*/
public function remove_rules() {
require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
$manager = Vigilante_Htaccess_Manager::get_instance();
$result = $manager->remove_block( self::MARKER_START, self::MARKER_END );
if ( true === $result ) {
/** This action is documented in class-wpconfig-security.php */
do_action( 'vigilante_critical_file_written', '.htaccess' );
}
return $result;
}
/**
* Check if rules are active
*
* @return bool
*/
public function are_rules_active() {
require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
$manager = Vigilante_Htaccess_Manager::get_instance();
return $manager->block_exists( self::MARKER_START );
}
/**
* Check if CSP is restrictive (could break WordPress admin/editor)
*
* WordPress block editor (Gutenberg) requires:
* - script-src: 'unsafe-inline' 'unsafe-eval' (for React)
* - style-src: 'unsafe-inline' (for dynamic styles)
* - frame-src: blob: (for iframe previews)
* - worker-src: blob: (for web workers)
* - connect-src: blob: (for the client-side media processing of WP 7.1+)
*
* @return bool True if CSP would likely break the admin interface.
*/
public function is_csp_restrictive() {
if ( empty( $this->options['csp']['enabled'] ) ) {
return false;
}
// If report-only mode, it won't actually block anything
if ( ! empty( $this->options['csp']['report_only'] ) ) {
return false;
}
$directives = $this->options['csp']['directives'] ?? array();
// Check script-src for required values
$script_src = $directives['script-src'] ?? '';
if ( ! empty( $script_src ) ) {
// Gutenberg needs 'unsafe-inline' and 'unsafe-eval'
$has_unsafe_inline = ( false !== strpos( $script_src, "'unsafe-inline'" ) );
$has_unsafe_eval = ( false !== strpos( $script_src, "'unsafe-eval'" ) );
$has_nonce = ( false !== strpos( $script_src, "'nonce-" ) );
// If no unsafe-inline and no nonce, it's restrictive
if ( ! $has_unsafe_inline && ! $has_nonce ) {
return true;
}
// Gutenberg specifically needs unsafe-eval for React
if ( ! $has_unsafe_eval && ! $has_nonce ) {
return true;
}
}
// Check style-src for required values
$style_src = $directives['style-src'] ?? '';
if ( ! empty( $style_src ) ) {
$has_unsafe_inline = ( false !== strpos( $style_src, "'unsafe-inline'" ) );
$has_nonce = ( false !== strpos( $style_src, "'nonce-" ) );
if ( ! $has_unsafe_inline && ! $has_nonce ) {
return true;
}
}
// Check frame-src for blob: (required by Gutenberg for iframe previews)
$frame_src = $directives['frame-src'] ?? '';
if ( ! empty( $frame_src ) && false === strpos( $frame_src, 'blob:' ) ) {
// Only restrictive if frame-src is set and doesn't include blob:
// Check if it's set to 'none' which would definitely block
if ( false !== strpos( $frame_src, "'none'" ) ) {
return true;
}
// If frame-src is explicitly set without blob:, it's restrictive
return true;
}
// Check worker-src for blob: (required for web workers)
$worker_src = $directives['worker-src'] ?? '';
if ( ! empty( $worker_src ) && false === strpos( $worker_src, 'blob:' ) ) {
if ( false !== strpos( $worker_src, "'none'" ) ) {
return true;
}
}
// Check connect-src for blob:. WordPress 7.1 processes images in the
// browser before uploading them, and @wordpress/vips fetches its
// WebAssembly binary from a blob: URL. fetch() answers to connect-src,
// and 'self' does not cover blob:, so without it the upload fails while
// WordPress still believes the feature is supported (its own detection
// only tests blob: workers, which worker-src above already allows).
$connect_src = $directives['connect-src'] ?? '';
if ( ! empty( $connect_src ) && false === strpos( $connect_src, 'blob:' ) ) {
return true;
}
return false;
}
/**
* Get specific CSP issues that could affect WordPress
*
* @return array List of issues with directives.
*/
public function get_csp_compatibility_issues() {
$issues = array();
if ( empty( $this->options['csp']['enabled'] ) ) {
return $issues;
}
if ( ! empty( $this->options['csp']['report_only'] ) ) {
return $issues;
}
$directives = $this->options['csp']['directives'] ?? array();
// Check script-src
$script_src = $directives['script-src'] ?? '';
if ( ! empty( $script_src ) ) {
if ( false === strpos( $script_src, "'unsafe-inline'" ) && false === strpos( $script_src, "'nonce-" ) ) {
$issues[] = array(
'directive' => 'script-src',
'issue' => __( 'Missing \'unsafe-inline\' - may break admin scripts', 'vigilante' ),
'severity' => 'high',
);
}
if ( false === strpos( $script_src, "'unsafe-eval'" ) ) {
$issues[] = array(
'directive' => 'script-src',
'issue' => __( 'Missing \'unsafe-eval\' - will break the block editor (Gutenberg)', 'vigilante' ),
'severity' => 'high',
);
}
}
// Check style-src
$style_src = $directives['style-src'] ?? '';
if ( ! empty( $style_src ) && false === strpos( $style_src, "'unsafe-inline'" ) ) {
$issues[] = array(
'directive' => 'style-src',
'issue' => __( 'Missing \'unsafe-inline\' - may break admin styles', 'vigilante' ),
'severity' => 'medium',
);
}
// Check frame-src
$frame_src = $directives['frame-src'] ?? '';
if ( ! empty( $frame_src ) && false === strpos( $frame_src, 'blob:' ) ) {
$issues[] = array(
'directive' => 'frame-src',
'issue' => __( 'Missing \'blob:\' - will break the block editor previews', 'vigilante' ),
'severity' => 'high',
);
}
// Check worker-src
$worker_src = $directives['worker-src'] ?? '';
if ( ! empty( $worker_src ) && false === strpos( $worker_src, 'blob:' ) ) {
$issues[] = array(
'directive' => 'worker-src',
'issue' => __( 'Missing \'blob:\' - may break background processing', 'vigilante' ),
'severity' => 'low',
);
}
// Check connect-src
$connect_src = $directives['connect-src'] ?? '';
if ( ! empty( $connect_src ) && false === strpos( $connect_src, 'blob:' ) ) {
$issues[] = array(
'directive' => 'connect-src',
'issue' => __( 'Missing \'blob:\' - will break image uploads from the editor on WordPress 7.1 and later', 'vigilante' ),
'severity' => 'high',
);
}
return $issues;
}
/**
* Make a settings value safe to interpolate into an .htaccess directive.
*
* Every value below is written inside a double-quoted argument of a
* "Header always set" line. Two characters break out of that argument:
*
* - A line break ends the directive and turns whatever follows into a new
* Apache directive, which is arbitrary server configuration.
* - A double quote closes the argument early and leaves the remainder as
* stray arguments, which Apache rejects with a 500.
*
* No header value this plugin writes legitimately contains either: CSP
* source expressions use single quotes ('self', 'unsafe-inline'), and the
* rest are single-token values from fixed lists. Applied at generation time
* rather than at save time so the guard holds however the value reached the
* option (a crafted request, an imported settings file, a direct write).
*
* @param mixed $value Raw settings value.
* @return string
*/
private function sanitize_header_value( $value ) {
return str_replace( array( '"', "\r", "\n" ), '', (string) $value );
}
/**
* Generate rules content (without markers)
*
* Public since 2.10.0: it is also "what this configuration would write right
* now", which is what Vigilante_Htaccess_Recovery compares the file against.
*
* @return string
*/
public function generate_rules_content() {
$rules = array();
$rules[] = '# Vigilante - Security Headers';
$rules[] = '# Generated: ' . gmdate( 'Y-m-d H:i:s' ) . ' UTC';
// Check if CSP is restrictive (could break admin interface)
$is_csp_restrictive = $this->is_csp_restrictive();
// If CSP is restrictive, we need to skip it for wp-admin to prevent breaking the admin interface
if ( $is_csp_restrictive ) {
$rules[] = '';
$rules[] = '# Skip restrictive CSP for WordPress admin area to prevent breaking the editor';
$rules[] = '';
$rules[] = ' SetEnvIf Request_URI "^/wp-admin" VIGILANTE_SKIP_CSP';
$rules[] = ' SetEnvIf Request_URI "admin-ajax\\.php$" VIGILANTE_SKIP_CSP';
$rules[] = ' SetEnvIf Request_URI "wp-login\\.php$" VIGILANTE_SKIP_CSP';
$rules[] = '';
}
$rules[] = '';
$rules[] = '';
// X-Frame-Options
if ( ! empty( $this->options['x_frame_options'] ) ) {
$value = $this->options['x_frame_options'];
$rules[] = ' # Clickjacking protection';
$rules[] = ' Header always set X-Frame-Options "' . $this->sanitize_header_value( $value ) . '"';
}
// X-Content-Type-Options
if ( ! empty( $this->options['x_content_type_options'] ) ) {
$rules[] = ' # Prevent MIME type sniffing';
$rules[] = ' Header always set X-Content-Type-Options "nosniff"';
}
// Referrer-Policy
if ( ! empty( $this->options['referrer_policy'] ) ) {
$value = $this->options['referrer_policy'];
$rules[] = ' # Referrer Policy';
$rules[] = ' Header always set Referrer-Policy "' . $this->sanitize_header_value( $value ) . '"';
}
// Strict-Transport-Security (HSTS)
if ( ! empty( $this->options['hsts']['enabled'] ) ) {
$hsts = $this->options['hsts'];
$value = 'max-age=' . absint( $hsts['max_age'] );
if ( ! empty( $hsts['include_subdomains'] ) ) {
$value .= '; includeSubDomains';
}
if ( ! empty( $hsts['preload'] ) ) {
$value .= '; preload';
}
$rules[] = ' # HTTP Strict Transport Security';
$rules[] = ' Header always set Strict-Transport-Security "' . $value . '"';
}
// Permissions-Policy
if ( ! empty( $this->options['permissions_policy']['enabled'] ) ) {
$permissions = $this->options['permissions_policy'];
$directives = array();
$policy_items = array(
'geolocation', 'microphone', 'camera', 'payment',
'usb', 'magnetometer', 'gyroscope', 'accelerometer',
);
foreach ( $policy_items as $item ) {
if ( isset( $permissions[ $item ] ) ) {
$directives[] = $item . '=' . $permissions[ $item ];
}
}
if ( ! empty( $directives ) ) {
$rules[] = ' # Permissions Policy';
$rules[] = ' Header always set Permissions-Policy "' . $this->sanitize_header_value( implode( ', ', $directives ) ) . '"';
}
}
// Content-Security-Policy
if ( ! empty( $this->options['csp']['enabled'] ) ) {
$csp = $this->options['csp'];
$directives = array();
// Synchronize frame-ancestors with X-Frame-Options
$frame_ancestors_value = '';
if ( ! empty( $this->options['x_frame_options'] ) ) {
if ( 'DENY' === $this->options['x_frame_options'] ) {
$frame_ancestors_value = "'none'";
} elseif ( 'SAMEORIGIN' === $this->options['x_frame_options'] ) {
$frame_ancestors_value = "'self'";
}
}
if ( ! empty( $csp['directives'] ) && is_array( $csp['directives'] ) ) {
foreach ( $csp['directives'] as $directive => $value ) {
// Override frame-ancestors with synchronized value if X-Frame-Options is set
if ( 'frame-ancestors' === $directive && ! empty( $frame_ancestors_value ) ) {
$directives[] = $directive . ' ' . $frame_ancestors_value;
} elseif ( true === $value ) {
$directives[] = $directive;
} elseif ( false !== $value && ! empty( $value ) ) {
$directives[] = $directive . ' ' . $value;
}
}
}
// Add frame-ancestors if not already present but X-Frame-Options is set
if ( ! empty( $frame_ancestors_value ) ) {
$has_frame_ancestors = false;
foreach ( $directives as $dir ) {
if ( 0 === strpos( $dir, 'frame-ancestors' ) ) {
$has_frame_ancestors = true;
break;
}
}
if ( ! $has_frame_ancestors ) {
$directives[] = 'frame-ancestors ' . $frame_ancestors_value;
}
}
if ( ! empty( $directives ) ) {
$header_value = implode( '; ', $directives );
// The value travels inside a double-quoted argument of the
// "Header always set" line below. A double quote in a directive
// would close that argument early and leave the rest of the
// policy as stray arguments, which Apache rejects with a 500.
// CSP source expressions use single quotes ('self',
// 'unsafe-inline'), never double ones, so no legitimate policy
// can be affected. Done here rather than at save time so the
// guard holds however the value reached the option.
$header_value = $this->sanitize_header_value( $header_value );
if ( ! empty( $csp['report_uri'] ) ) {
$header_value .= '; report-uri ' . $this->sanitize_header_value( esc_url( $csp['report_uri'] ) );
}
$header_name = ! empty( $csp['report_only'] )
? 'Content-Security-Policy-Report-Only'
: 'Content-Security-Policy';
$rules[] = ' # Content Security Policy';
// If CSP is restrictive, only apply it outside wp-admin
if ( $is_csp_restrictive ) {
$rules[] = ' # Note: Restrictive CSP skipped for wp-admin to prevent breaking the block editor';
$rules[] = ' Header always set ' . $header_name . ' "' . $header_value . '" env=!VIGILANTE_SKIP_CSP';
} else {
$rules[] = ' Header always set ' . $header_name . ' "' . $header_value . '"';
}
}
}
/*
* Cross-Origin policies.
*
* Allow-listed rather than escaped, for the same reason sanitize_header_value()
* runs at generation time: these three became editable in 2.10.0 and their value
* is written verbatim inside a Header directive. Only the tokens the specs define
* are ever emitted, so an unexpected value (a crafted request, an imported
* settings file, a direct write to the option) drops the header instead of
* reaching the .htaccess.
*
* COEP deliberately omits unsafe-none: it is the browser default, so emitting it
* adds nothing. That was the behaviour before this list existed and it is kept.
*/
if ( ! empty( $this->options['cross_origin_policies'] ) ) {
$policies = $this->options['cross_origin_policies'];
$coep = isset( $policies['embedder_policy'] ) ? (string) $policies['embedder_policy'] : '';
if ( in_array( $coep, array( 'require-corp', 'credentialless' ), true ) ) {
$rules[] = ' Header always set Cross-Origin-Embedder-Policy "' . $coep . '"';
}
$coop = isset( $policies['opener_policy'] ) ? (string) $policies['opener_policy'] : '';
if ( in_array( $coop, array( 'unsafe-none', 'same-origin-allow-popups', 'same-origin' ), true ) ) {
$rules[] = ' Header always set Cross-Origin-Opener-Policy "' . $coop . '"';
}
$corp = isset( $policies['resource_policy'] ) ? (string) $policies['resource_policy'] : '';
if ( in_array( $corp, array( 'same-site', 'same-origin', 'cross-origin' ), true ) ) {
$rules[] = ' Header always set Cross-Origin-Resource-Policy "' . $corp . '"';
}
}
// Remove X-Powered-By
$rules[] = ' # Hide PHP version';
$rules[] = ' Header always unset X-Powered-By';
$rules[] = '';
return implode( "\n", $rules );
}
/**
* Get security grade based on enabled headers
*
* @return array
*/
public function get_security_grade() {
$score = 0;
$enabled = array();
$missing = array();
$warnings = array();
// X-Frame-Options (15 points)
if ( ! empty( $this->options['x_frame_options'] ) ) {
$score += 15;
$enabled[] = 'X-Frame-Options: ' . $this->options['x_frame_options'];
} else {
$missing[] = 'X-Frame-Options';
}
// X-Content-Type-Options (15 points)
if ( ! empty( $this->options['x_content_type_options'] ) ) {
$score += 15;
$enabled[] = 'X-Content-Type-Options: nosniff';
} else {
$missing[] = 'X-Content-Type-Options';
}
// HSTS (20 points)
if ( ! empty( $this->options['hsts']['enabled'] ) ) {
$hsts = $this->options['hsts'];
if ( $hsts['max_age'] >= 31536000 ) {
$score += 20;
} else {
$score += 10;
$warnings[] = 'HSTS max-age should be at least 1 year (31536000 seconds)';
}
$enabled[] = 'Strict-Transport-Security';
} else {
$missing[] = 'Strict-Transport-Security (HSTS)';
}
// CSP (30 points; absorbs the 10 points freed by retiring the deprecated X-XSS-Protection)
if ( ! empty( $this->options['csp']['enabled'] ) ) {
if ( empty( $this->options['csp']['report_only'] ) ) {
$score += 30;
$enabled[] = 'Content-Security-Policy';
// Add warning if CSP is restrictive
if ( $this->is_csp_restrictive() ) {
$warnings[] = __( 'Restrictive CSP detected. Admin area is automatically excluded to prevent breaking the dashboard and block editor.', 'vigilante' );
}
// Check for specific compatibility issues
$csp_issues = $this->get_csp_compatibility_issues();
foreach ( $csp_issues as $issue ) {
if ( 'high' === $issue['severity'] ) {
$warnings[] = sprintf(
/* translators: 1: CSP directive name, 2: Issue description */
__( 'CSP %1$s: %2$s', 'vigilante' ),
$issue['directive'],
$issue['issue']
);
}
}
} else {
$score += 15;
$enabled[] = 'Content-Security-Policy-Report-Only';
$warnings[] = 'CSP is in report-only mode (recommended for testing)';
}
} else {
$missing[] = 'Content-Security-Policy';
}
// Referrer-Policy (10 points)
if ( ! empty( $this->options['referrer_policy'] ) ) {
$score += 10;
$enabled[] = 'Referrer-Policy: ' . $this->options['referrer_policy'];
} else {
$missing[] = 'Referrer-Policy';
}
// Permissions-Policy (10 points)
if ( ! empty( $this->options['permissions_policy']['enabled'] ) ) {
$score += 10;
$enabled[] = 'Permissions-Policy';
} else {
$missing[] = 'Permissions-Policy';
}
// Calculate grade
if ( $score >= 90 ) {
$grade = 'A';
} elseif ( $score >= 80 ) {
$grade = 'B';
} elseif ( $score >= 70 ) {
$grade = 'C';
} elseif ( $score >= 60 ) {
$grade = 'D';
} else {
$grade = 'F';
}
return array(
'grade' => $grade,
'score' => $score,
'headers' => $enabled,
'missing' => $missing,
'warnings' => $warnings,
);
}
}