PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.8
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.8
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-security-headers.php +93 -49 2.9.52.11.8 View file →
@@ -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' );
@@ -119,8 +124,9 @@
119 124 * - script-src: 'unsafe-inline' 'unsafe-eval' (for React)
120 125 * - style-src: 'unsafe-inline' (for dynamic styles)
121 126 * - frame-src: blob: (for iframe previews)
122 127 * - worker-src: blob: (for web workers)
128 + * - connect-src: blob: (for the client-side media processing of WP 7.1+)
123 129 *
124 130 * @return bool True if CSP would likely break the admin interface.
125 131 */
126 132 public function is_csp_restrictive() {
@@ -184,8 +190,19 @@
184 190 return true;
185 191 }
186 192 }
187 193
194 + // Check connect-src for blob:. WordPress 7.1 processes images in the
195 + // browser before uploading them, and @wordpress/vips fetches its
196 + // WebAssembly binary from a blob: URL. fetch() answers to connect-src,
197 + // and 'self' does not cover blob:, so without it the upload fails while
198 + // WordPress still believes the feature is supported (its own detection
199 + // only tests blob: workers, which worker-src above already allows).
200 + $connect_src = $directives['connect-src'] ?? '';
201 + if ( ! empty( $connect_src ) && false === strpos( $connect_src, 'blob:' ) ) {
202 + return true;
203 + }
204 +
188 205 return false;
189 206 }
190 207
191 208 /**
@@ -254,17 +271,54 @@
254 271 'severity' => 'low',
255 272 );
256 273 }
257 274
275 + // Check connect-src
276 + $connect_src = $directives['connect-src'] ?? '';
277 + if ( ! empty( $connect_src ) && false === strpos( $connect_src, 'blob:' ) ) {
278 + $issues[] = array(
279 + 'directive' => 'connect-src',
280 + 'issue' => __( 'Missing \'blob:\' - will break image uploads from the editor on WordPress 7.1 and later', 'vigilante' ),
281 + 'severity' => 'high',
282 + );
283 + }
284 +
258 285 return $issues;
259 286 }
260 287
261 288 /**
289 + * Make a settings value safe to interpolate into an .htaccess directive.
290 + *
291 + * Every value below is written inside a double-quoted argument of a
292 + * "Header always set" line. Two characters break out of that argument:
293 + *
294 + * - A line break ends the directive and turns whatever follows into a new
295 + * Apache directive, which is arbitrary server configuration.
296 + * - A double quote closes the argument early and leaves the remainder as
297 + * stray arguments, which Apache rejects with a 500.
298 + *
299 + * No header value this plugin writes legitimately contains either: CSP
300 + * source expressions use single quotes ('self', 'unsafe-inline'), and the
301 + * rest are single-token values from fixed lists. Applied at generation time
302 + * rather than at save time so the guard holds however the value reached the
303 + * option (a crafted request, an imported settings file, a direct write).
304 + *
305 + * @param mixed $value Raw settings value.
306 + * @return string
307 + */
308 + private function sanitize_header_value( $value ) {
309 + return str_replace( array( '"', "\r", "\n" ), '', (string) $value );
310 + }
311 +
312 + /**
262 313 * Generate rules content (without markers)
263 314 *
315 + * Public since 2.10.0: it is also "what this configuration would write right
316 + * now", which is what Vigilante_Htaccess_Recovery compares the file against.
317 + *
264 318 * @return string
265 319 */
266 - private function generate_rules_content() {
320 + public function generate_rules_content() {
267 321 $rules = array();
268 322
269 323 $rules[] = '# Vigilante - Security Headers';
270 324 $rules[] = '# Generated: ' . gmdate( 'Y-m-d H:i:s' ) . ' UTC';
@@ -289,9 +343,9 @@
289 343 // X-Frame-Options
290 344 if ( ! empty( $this->options['x_frame_options'] ) ) {
291 345 $value = $this->options['x_frame_options'];
292 346 $rules[] = ' # Clickjacking protection';
293 - $rules[] = ' Header always set X-Frame-Options "' . esc_attr( $value ) . '"';
347 + $rules[] = ' Header always set X-Frame-Options "' . $this->sanitize_header_value( $value ) . '"';
294 348 }
295 349
296 350 // X-Content-Type-Options
297 351 if ( ! empty( $this->options['x_content_type_options'] ) ) {
@@ -302,9 +356,9 @@
302 356 // Referrer-Policy
303 357 if ( ! empty( $this->options['referrer_policy'] ) ) {
304 358 $value = $this->options['referrer_policy'];
305 359 $rules[] = ' # Referrer Policy';
306 - $rules[] = ' Header always set Referrer-Policy "' . esc_attr( $value ) . '"';
360 + $rules[] = ' Header always set Referrer-Policy "' . $this->sanitize_header_value( $value ) . '"';
307 361 }
308 362
309 363 // Strict-Transport-Security (HSTS)
310 364 if ( ! empty( $this->options['hsts']['enabled'] ) ) {
@@ -340,9 +394,9 @@
340 394 }
341 395
342 396 if ( ! empty( $directives ) ) {
343 397 $rules[] = ' # Permissions Policy';
344 - $rules[] = ' Header always set Permissions-Policy "' . implode( ', ', $directives ) . '"';
398 + $rules[] = ' Header always set Permissions-Policy "' . $this->sanitize_header_value( implode( ', ', $directives ) ) . '"';
345 399 }
346 400 }
347 401
348 402 // Content-Security-Policy
@@ -389,10 +443,20 @@
389 443
390 444 if ( ! empty( $directives ) ) {
391 445 $header_value = implode( '; ', $directives );
392 446
447 + // The value travels inside a double-quoted argument of the
448 + // "Header always set" line below. A double quote in a directive
449 + // would close that argument early and leave the rest of the
450 + // policy as stray arguments, which Apache rejects with a 500.
451 + // CSP source expressions use single quotes ('self',
452 + // 'unsafe-inline'), never double ones, so no legitimate policy
453 + // can be affected. Done here rather than at save time so the
454 + // guard holds however the value reached the option.
455 + $header_value = $this->sanitize_header_value( $header_value );
456 +
393 457 if ( ! empty( $csp['report_uri'] ) ) {
394 - $header_value .= '; report-uri ' . esc_url( $csp['report_uri'] );
458 + $header_value .= '; report-uri ' . $this->sanitize_header_value( esc_url( $csp['report_uri'] ) );
395 459 }
396 460
397 461 $header_name = ! empty( $csp['report_only'] )
398 462 ? 'Content-Security-Policy-Report-Only'
@@ -409,22 +473,37 @@
409 473 }
410 474 }
411 475 }
412 476
413 - // Cross-Origin policies
477 + /*
478 + * Cross-Origin policies.
479 + *
480 + * Allow-listed rather than escaped, for the same reason sanitize_header_value()
481 + * runs at generation time: these three became editable in 2.10.0 and their value
482 + * is written verbatim inside a Header directive. Only the tokens the specs define
483 + * are ever emitted, so an unexpected value (a crafted request, an imported
484 + * settings file, a direct write to the option) drops the header instead of
485 + * reaching the .htaccess.
486 + *
487 + * COEP deliberately omits unsafe-none: it is the browser default, so emitting it
488 + * adds nothing. That was the behaviour before this list existed and it is kept.
489 + */
414 490 if ( ! empty( $this->options['cross_origin_policies'] ) ) {
415 491 $policies = $this->options['cross_origin_policies'];
416 492
417 - if ( ! empty( $policies['embedder_policy'] ) && 'unsafe-none' !== $policies['embedder_policy'] ) {
418 - $rules[] = ' Header always set Cross-Origin-Embedder-Policy "' . esc_attr( $policies['embedder_policy'] ) . '"';
493 + $coep = isset( $policies['embedder_policy'] ) ? (string) $policies['embedder_policy'] : '';
494 + if ( in_array( $coep, array( 'require-corp', 'credentialless' ), true ) ) {
495 + $rules[] = ' Header always set Cross-Origin-Embedder-Policy "' . $coep . '"';
419 496 }
420 497
421 - if ( ! empty( $policies['opener_policy'] ) ) {
422 - $rules[] = ' Header always set Cross-Origin-Opener-Policy "' . esc_attr( $policies['opener_policy'] ) . '"';
498 + $coop = isset( $policies['opener_policy'] ) ? (string) $policies['opener_policy'] : '';
499 + if ( in_array( $coop, array( 'unsafe-none', 'same-origin-allow-popups', 'same-origin' ), true ) ) {
500 + $rules[] = ' Header always set Cross-Origin-Opener-Policy "' . $coop . '"';
423 501 }
424 502
425 - if ( ! empty( $policies['resource_policy'] ) ) {
426 - $rules[] = ' Header always set Cross-Origin-Resource-Policy "' . esc_attr( $policies['resource_policy'] ) . '"';
503 + $corp = isset( $policies['resource_policy'] ) ? (string) $policies['resource_policy'] : '';
504 + if ( in_array( $corp, array( 'same-site', 'same-origin', 'cross-origin' ), true ) ) {
505 + $rules[] = ' Header always set Cross-Origin-Resource-Policy "' . $corp . '"';
427 506 }
428 507 }
429 508
430 509 // Remove X-Powered-By
@@ -433,43 +512,8 @@
433 512
434 513 $rules[] = '</IfModule>';
435 514
436 515 return implode( "\n", $rules );
437 - }
438 -
439 - /**
440 - * Get headers preview
441 - *
442 - * @return array
443 - */
444 - public function get_headers_preview() {
445 - $headers = array();
446 -
447 - if ( ! empty( $this->options['x_frame_options'] ) ) {
448 - $headers['X-Frame-Options'] = $this->options['x_frame_options'];
449 - }
450 -
451 - if ( ! empty( $this->options['x_content_type_options'] ) ) {
452 - $headers['X-Content-Type-Options'] = 'nosniff';
453 - }
454 -
455 - if ( ! empty( $this->options['referrer_policy'] ) ) {
456 - $headers['Referrer-Policy'] = $this->options['referrer_policy'];
457 - }
458 -
459 - if ( ! empty( $this->options['hsts']['enabled'] ) ) {
460 - $hsts = $this->options['hsts'];
461 - $value = 'max-age=' . absint( $hsts['max_age'] );
462 - if ( ! empty( $hsts['include_subdomains'] ) ) {
463 - $value .= '; includeSubDomains';
464 - }
465 - if ( ! empty( $hsts['preload'] ) ) {
466 - $value .= '; preload';
467 - }
468 - $headers['Strict-Transport-Security'] = $value;
469 - }
470 -
471 - return $headers;
472 516 }
473 517
474 518 /**
475 519 * Get security grade based on enabled headers