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