> */ private static function header_defs(): array { return array( 'strict-transport-security' => array( 'name' => 'Strict-Transport-Security', 'default' => 'max-age=31536000; includeSubDomains', 'fixed' => false, 'risk' => 'safe', 'rec' => 'βœ… Safe, Highly recommended', 'desc' => 'Forces browsers to use HTTPS exclusively for 1 year. Prevents protocol downgrade attacks and cookie hijacking.', 'risk_note' => 'CloudScale only sends this header over HTTPS, it is never sent on an HTTP connection. Safe to enable immediately.', 'presets' => array( 'max-age=31536000; includeSubDomains' => '1 yr + subdomains', 'max-age=31536000' => '1 yr, main domain only', 'max-age=86400' => '1 day (testing only)', ), ), 'x-content-type-options' => array( 'name' => 'X-Content-Type-Options', 'default' => 'nosniff', 'fixed' => true, 'risk' => 'safe', 'rec' => 'βœ… Safe, Zero risk', 'desc' => 'Prevents browsers from MIME-sniffing away from the declared content type. Stops certain XSS attacks via content confusion.', 'risk_note' => 'Value is always "nosniff". No configuration needed, no known compatibility issues.', 'presets' => array(), ), 'x-frame-options' => array( 'name' => 'X-Frame-Options', 'default' => 'SAMEORIGIN', 'fixed' => false, 'risk' => 'low', 'rec' => '🟑 Low risk, Review if your site is embedded elsewhere', 'desc' => 'Controls whether your site can appear inside an iframe on another domain. Prevents clickjacking attacks.', 'risk_note' => 'SAMEORIGIN allows embedding on your own domain only. If a partner site or tool legitimately embeds your pages in an iframe, you may need to disable this header for that use case.', 'presets' => array( 'SAMEORIGIN' => 'SAMEORIGIN (recommended)', 'DENY' => 'DENY (most restrictive)', ), ), 'referrer-policy' => array( 'name' => 'Referrer-Policy', 'default' => 'strict-origin-when-cross-origin', 'fixed' => false, 'risk' => 'low', 'rec' => '🟑 Low risk, May reduce referrer data in analytics', 'desc' => 'Controls how much of the page URL is sent to other sites when a user follows a link away from your site.', 'risk_note' => '"strict-origin-when-cross-origin" sends only the domain (not the path) to external sites. GA4 handles this correctly. If analytics referrers look wrong, switch to "no-referrer-when-downgrade".', 'presets' => array( 'strict-origin-when-cross-origin' => 'strict-origin-when-cross-origin (recommended)', 'no-referrer-when-downgrade' => 'no-referrer-when-downgrade (more referrer data)', 'same-origin' => 'same-origin', 'no-referrer' => 'no-referrer (most private)', ), ), 'permissions-policy' => array( 'name' => 'Permissions-Policy', 'default' => 'camera=(), microphone=(), geolocation=(), payment=()', 'fixed' => false, 'risk' => 'review', 'rec' => 'πŸ”§ Configure, Untick any API your site uses', 'desc' => 'Restricts which browser features this page and its iframes can access. Each checkbox blocks one API.', 'risk_note' => 'Tick to block each browser API. Untick any that your site actually uses, maps need Geolocation, WooCommerce / Apple Pay / passkeys need Payment API.', 'presets' => array(), 'directives' => array( 'camera' => array( 'label' => 'Camera' ), 'microphone' => array( 'label' => 'Microphone' ), 'geolocation' => array( 'label' => 'Geolocation' ), 'payment' => array( 'label' => 'Payment API' ), ), ), ); } /** @return array> */ private static function load_headers_config(): array { $defs = self::header_defs(); $config_raw = get_option( 'csdt_sec_headers_config', null ); if ( null === $config_raw ) { // Migrate from legacy all-or-nothing flag. $legacy = CSDT_Options::get( 'csdt_devtools_safe_headers_enabled' ) === '1'; $config = array(); foreach ( $defs as $key => $def ) { $config[ $key ] = array( 'enabled' => $legacy, 'value' => $def['default'], ); } } else { $config = json_decode( (string) $config_raw, true ); if ( ! is_array( $config ) ) { $config = array(); } foreach ( $defs as $key => $def ) { if ( ! isset( $config[ $key ] ) ) { $config[ $key ] = array( 'enabled' => false, 'value' => $def['default'], ); } } } return $config; } public static function render_security_headers_panel( bool $show_divider = true ): void { $defs = self::header_defs(); $config = self::load_headers_config(); $ext_ack = get_option( 'csdt_devtools_sec_headers_ack', '0' ) === '1'; $passkey_active = CSDT_Options::get( 'csdt_devtools_2fa_method' ) === 'passkey'; $woo_active = function_exists( 'WC' ) || class_exists( 'WooCommerce' ); $risk_styles = array( 'safe' => array( 'bg' => '#dcfce7', 'color' => '#15803d', 'border' => '#86efac', ), 'low' => array( 'bg' => '#fef9c3', 'color' => '#854d0e', 'border' => '#fde047', ), 'review' => array( 'bg' => '#fff7ed', 'color' => '#c2410c', 'border' => '#fed7aa', ), ); ?>
{enabled, value}; header values carry no markup, so // the textarea sanitiser (which keeps quotes and braces) loses nothing a header needs. $incoming_raw = isset( $_POST['headers_config'] ) ? sanitize_textarea_field( wp_unslash( $_POST['headers_config'] ) ) : '{}'; $incoming = json_decode( (string) $incoming_raw, true ); if ( ! is_array( $incoming ) ) { $incoming = array(); } $allowed_keys = array_keys( self::header_defs() ); $fixed_values = array( 'x-content-type-options' => 'nosniff' ); $safe_config = array(); foreach ( $allowed_keys as $k ) { $entry = $incoming[ $k ] ?? array( 'enabled' => false, 'value' => '', ); $enabled = ! empty( $entry['enabled'] ); $value = $fixed_values[ $k ] ?? sanitize_text_field( $entry['value'] ?? '' ); $safe_config[ $k ] = array( 'enabled' => $enabled, 'value' => $value, ); } // Master switch is sent separately from per-header enabled states so individual header // flags reflect only checkbox state, keeping history labels accurate. $master_on = isset( $_POST['master'] ) && '1' === sanitize_key( wp_unslash( $_POST['master'] ) ); $any_enabled = $master_on && (bool) array_reduce( $safe_config, static fn( $c, $v ) => $c || ! empty( $v['enabled'] ), false ); // Capture old state for history. $old_config_raw = get_option( 'csdt_sec_headers_config', null ); $old_config = null !== $old_config_raw ? json_decode( (string) $old_config_raw, true ) : null; $old_ext_ack = get_option( 'csdt_devtools_sec_headers_ack', '0' ); $old_enabled = CSDT_Options::get( 'csdt_devtools_safe_headers_enabled' ); // Build a human-readable change label. $short = array( 'strict-transport-security' => 'HSTS', 'x-content-type-options' => 'X-Content-Type-Options', 'x-frame-options' => 'X-Frame-Options', 'referrer-policy' => 'Referrer-Policy', 'permissions-policy' => 'Permissions-Policy', ); $parts = array(); if ( is_array( $old_config ) ) { $on = array(); $off = array(); $changed = array(); foreach ( $allowed_keys as $k ) { $old_en = ! empty( $old_config[ $k ]['enabled'] ); $new_en = $safe_config[ $k ]['enabled']; $old_val = trim( $old_config[ $k ]['value'] ?? '' ); $new_val = trim( $safe_config[ $k ]['value'] ?? '' ); if ( ! $old_en && $new_en ) { $on[] = $short[ $k ]; } elseif ( $old_en && ! $new_en ) { $off[] = $short[ $k ]; } elseif ( $old_en && $old_val !== $new_val ) { $changed[] = $short[ $k ]; } } if ( $on ) { $parts[] = 'Enabled: ' . implode( ', ', $on ); } if ( $off ) { $parts[] = 'Disabled: ' . implode( ', ', $off ); } if ( $changed ) { $parts[] = 'Updated value: ' . implode( ', ', $changed ); } } else { $on = array_filter( $safe_config, static fn( $v ) => ! empty( $v['enabled'] ) ); if ( $on ) { $parts[] = 'Enabled: ' . implode( ', ', array_map( static fn( $k ) => $short[ $k ], array_keys( $on ) ) ); } } if ( $old_ext_ack !== $ext_ack ) { $parts[] = $ext_ack === '1' ? 'Set Externally on' : 'Set Externally off'; } $label = $parts ? implode( '; ', $parts ) : 'Settings saved'; // Push old state to rolling 10-entry history. $history = json_decode( get_option( 'csdt_sec_headers_history', '[]' ), true ); if ( ! is_array( $history ) ) { $history = array(); } array_unshift( $history, array( 'enabled' => $old_enabled, 'ext_ack' => $old_ext_ack, 'headers_config' => null !== $old_config_raw ? (string) $old_config_raw : null, 'saved_at' => time(), 'label' => $label, ) ); update_option( 'csdt_sec_headers_history', wp_json_encode( array_slice( $history, 0, 10 ) ) ); update_option( 'csdt_sec_headers_config', wp_json_encode( $safe_config ) ); update_option( 'csdt_devtools_safe_headers_enabled', $master_on ? '1' : '0' ); update_option( 'csdt_devtools_sec_headers_ack', $ext_ack ); delete_transient( 'csdt_sec_headers_check' ); wp_send_json_success( array( 'history_entry' => array( 'enabled' => $old_enabled, 'ext_ack' => $old_ext_ack, 'saved_at' => time(), 'label' => $label, 'index' => 0, ), ) ); } public static function ajax_sec_headers_restore(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $idx = isset( $_POST['index'] ) ? absint( wp_unslash( $_POST['index'] ) ) : -1; $history = json_decode( get_option( 'csdt_sec_headers_history', '[]' ), true ); if ( ! is_array( $history ) || ! isset( $history[ $idx ] ) ) { wp_send_json_error( 'History entry not found.' ); } $entry = $history[ $idx ]; // Snapshot current live state into history before restoring. $current_raw = get_option( 'csdt_sec_headers_config', null ); array_unshift( $history, array( 'enabled' => CSDT_Options::get( 'csdt_devtools_safe_headers_enabled' ), 'ext_ack' => get_option( 'csdt_devtools_sec_headers_ack', '0' ), 'headers_config' => null !== $current_raw ? (string) $current_raw : null, 'saved_at' => time(), 'label' => 'Before restore to: ' . ( $entry['label'] ?? 'previous state' ), ) ); update_option( 'csdt_sec_headers_history', wp_json_encode( array_slice( $history, 0, 10 ) ) ); $restored_ext = $entry['ext_ack'] ?? '0'; update_option( 'csdt_devtools_sec_headers_ack', $restored_ext ); $response = array( 'enabled' => $entry['enabled'] ?? '0', 'ext_ack' => $restored_ext, ); if ( ! empty( $entry['headers_config'] ) ) { $restored_config = json_decode( (string) $entry['headers_config'], true ); if ( is_array( $restored_config ) ) { update_option( 'csdt_sec_headers_config', (string) $entry['headers_config'] ); // Restore the master switch state from the snapshot, not derived from individual // header flags (which may be enabled independently of master). update_option( 'csdt_devtools_safe_headers_enabled', $entry['enabled'] ?? '0' ); $response['headers_config'] = $restored_config; } } else { update_option( 'csdt_devtools_safe_headers_enabled', $entry['enabled'] ?? '0' ); } delete_transient( 'csdt_sec_headers_check' ); wp_send_json_success( $response ); } public static function ajax_scan_headers(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $sec_keys = array( 'content-security-policy', 'content-security-policy-report-only', 'strict-transport-security', 'x-frame-options', 'x-content-type-options', 'referrer-policy', 'permissions-policy', ); $mandatory = array( 'content-security-policy', 'strict-transport-security', 'x-frame-options', 'x-content-type-options' ); // ── Helper: analyse one URL ────────────────────────────────────────── $analyse = static function ( string $url ) use ( $sec_keys, $mandatory ): array { $resp = wp_remote_get( $url, array( 'timeout' => 10, 'sslverify' => csdt_loopback_ssl_verify(), 'redirection' => 3, 'user-agent' => 'CloudScale-Header-Scanner/1.0', ) ); if ( is_wp_error( $resp ) ) { return array( 'url' => $url, 'error' => CSDT_Error_Text::in_seconds( $resp->get_error_message() ), ); } $headers = wp_remote_retrieve_headers( $resp ); $status_code = (int) wp_remote_retrieve_response_code( $resp ); // Build a raw map of all header values to reliably catch duplicates. // WordPress's CaseInsensitiveDictionary sometimes concatenates duplicate // headers with a newline rather than returning an array. $raw_multi = array(); foreach ( $headers->getAll() as $raw_line ) { if ( strpos( $raw_line, ':' ) !== false ) { [ $k, $v ] = explode( ':', $raw_line, 2 ); $raw_multi[ strtolower( trim( $k ) ) ][] = trim( $v ); } } $sec = array(); foreach ( $sec_keys as $hkey ) { $val = $headers[ $hkey ] ?? null; // Prefer raw_multi count for accurate duplicate detection. $all_vals = $raw_multi[ $hkey ] ?? ( null !== $val ? ( is_array( $val ) ? $val : array( $val ) ) : array() ); if ( empty( $all_vals ) ) { $sec[ $hkey ] = array( 'status' => 'missing', 'values' => array(), ); } elseif ( count( $all_vals ) > 1 ) { $sec[ $hkey ] = array( 'status' => 'duplicate', 'values' => $all_vals, ); } else { $sec[ $hkey ] = array( 'status' => 'present', 'values' => $all_vals, ); } } return array( 'url' => $url, 'status_code' => $status_code, 'sec' => $sec, 'all_headers' => $headers->getAll(), ); }; // ── Homepage, full analysis ───────────────────────────────────────── $home_url = home_url( '/' ); $home_data = $analyse( $home_url ); // Grade + warnings from homepage $grade = 'A+'; $warnings = array(); if ( ! isset( $home_data['error'] ) ) { $sec = $home_data['sec']; $missing_mand = 0; foreach ( $mandatory as $hk ) { if ( ( $sec[ $hk ]['status'] ?? 'missing' ) === 'missing' ) { ++$missing_mand; } } // Grade by missing mandatory headers if ( $missing_mand >= 3 ) { $grade = 'F'; } elseif ( $missing_mand === 2 ) { $grade = 'D'; } elseif ( $missing_mand === 1 ) { // If the only missing mandatory header is enforced CSP but report-only is present, // give B, report-only means CSP is being tuned, not absent entirely. $only_csp_missing = ( $sec['content-security-policy']['status'] ?? 'missing' ) === 'missing' && ( $sec['strict-transport-security']['status'] ?? 'missing' ) !== 'missing' && ( $sec['x-frame-options']['status'] ?? 'missing' ) !== 'missing' && ( $sec['x-content-type-options']['status'] ?? 'missing' ) !== 'missing'; $has_report_only = ( $sec['content-security-policy-report-only']['status'] ?? 'missing' ) === 'present'; $grade = ( $only_csp_missing && $has_report_only ) ? 'B' : 'C'; } else { $grade = 'A+'; } // Warn when CSP is in report-only mode (not yet enforcing) if ( ( $sec['content-security-policy']['status'] ?? 'missing' ) === 'missing' && ( $sec['content-security-policy-report-only']['status'] ?? 'missing' ) === 'present' ) { $warnings[] = array( 'header' => 'Content-Security-Policy', 'msg' => 'CSP is in report-only mode, it logs violations but blocks nothing. Switch to enforcement in the Login Security β†’ Content Security Policy panel to reach grade A+.', ); } // CSP quality warnings $csp_val = $sec['content-security-policy']['values'][0] ?? ''; if ( $csp_val ) { if ( str_contains( $csp_val, "'unsafe-inline'" ) ) { $warnings[] = array( 'header' => 'Content-Security-Policy', 'msg' => "This policy contains 'unsafe-inline' which is dangerous in the script-src directive.", ); if ( $grade === 'A+' ) { $grade = 'A'; } } if ( str_contains( $csp_val, "'unsafe-eval'" ) ) { $warnings[] = array( 'header' => 'Content-Security-Policy', 'msg' => "This policy contains 'unsafe-eval' which allows dynamic code execution.", ); if ( in_array( $grade, array( 'A+', 'A' ), true ) ) { $grade = 'B'; } } // When strict-dynamic is active, only nonce'd scripts load, check that // every script element in the page HTML actually carries a nonce. if ( str_contains( $csp_val, "'strict-dynamic'" ) ) { $page_resp = wp_remote_get( $home_url, array( 'timeout' => 10, 'sslverify' => csdt_loopback_ssl_verify(), 'user-agent' => 'CloudScale-Header-Scanner/1.0', ) ); if ( ! is_wp_error( $page_resp ) ) { $body = wp_remote_retrieve_body( $page_resp ); preg_match_all( '/\s])([^>]*)>/i', $body, $all_scripts ); $missing = 0; foreach ( $all_scripts[1] as $attrs ) { if ( ! preg_match( '/\bnonce\s*=/i', $attrs ) ) { ++$missing; } } if ( $missing > 0 ) { $warnings[] = array( 'header' => 'Content-Security-Policy', 'msg' => "{$missing} script tag(s) on the homepage have no nonce. With 'strict-dynamic' active, these scripts will be blocked, third-party tags (AdSense, analytics) are common culprits. Enable the CloudScale nonce output buffer or add them via wp_enqueue_scripts.", ); if ( in_array( $grade, array( 'A+', 'A' ), true ) ) { $grade = 'B'; } } } } } // Duplicate headers foreach ( $sec as $hk => $hdata ) { if ( $hdata['status'] === 'duplicate' ) { $count = count( $hdata['values'] ); if ( $hk === 'content-security-policy' ) { // For CSP, the browser enforces the INTERSECTION of all policies, // AdSense, analytics, and other third-party scripts can be silently // blocked if the two policies have different allowlists. $warnings[] = array( 'header' => $hk, 'msg' => $count . ' Content-Security-Policy headers detected. The browser enforces ALL of them simultaneously (intersection, not first-wins), AdSense and third-party scripts may be blocked by the stricter policy. One source must be removed. Common causes: Nginx/Apache adding a static CSP while the plugin also sends one.', ); if ( in_array( $grade, array( 'A+', 'A', 'B' ), true ) ) { $grade = 'C'; } } else { $warnings[] = array( 'header' => $hk, 'msg' => $count . ' duplicate headers detected, browser behaviour is undefined. Check nginx/Apache config and plugin settings for conflicts.', ); if ( $grade === 'A+' ) { $grade = 'A'; } } } } // Optional headers missing foreach ( array( 'referrer-policy', 'permissions-policy' ) as $opt ) { if ( ( $sec[ $opt ]['status'] ?? 'missing' ) === 'missing' && in_array( $grade, array( 'A+', 'A' ), true ) ) { $grade = 'B'; } } // HSTS quality $hsts = $sec['strict-transport-security']['values'][0] ?? ''; if ( $hsts && preg_match( '/max-age=(\d+)/', $hsts, $m ) && (int) $m[1] < 31536000 ) { $warnings[] = array( 'header' => 'Strict-Transport-Security', 'msg' => 'max-age is less than 31536000 (1 year). Increase to at least 31536000.', ); if ( $grade === 'A+' ) { $grade = 'A'; } } $home_data['grade'] = $grade; $home_data['warnings'] = $warnings; // Server IP $host = wp_parse_url( $home_url, PHP_URL_HOST ); $home_data['ip'] = $host ? gethostbyname( $host ) : ''; } // ── Last 10 posts/pages, security headers only ────────────────────── $posts = get_posts( array( 'numberposts' => 10, 'post_status' => 'publish', 'post_type' => array( 'post', 'page' ), 'orderby' => 'date', 'order' => 'DESC', ) ); $page_results = array(); foreach ( $posts as $post ) { $purl = get_permalink( $post->ID ); if ( ! $purl || $purl === $home_url ) { continue; } $d = $analyse( $purl ); if ( isset( $d['sec'] ) ) { unset( $d['all_headers'] ); } // keep payload small $page_results[] = $d; } // Save to header scan history (last 20 entries). // // A scan driven by the Playwright suite is NOT recorded. Four specs click "Scan Headers // Now" per run, the history caps at 20, and on 2026-08-10 a few test runs wrote 14 rows // that evicted almost the entire real history, leaving a cliff from "3 days ago" // straight to "3 months ago" and an owner reasonably asking who had been scanning their // site. This is the same fault as blocks filling the activity log and pushing every // other security event out of it: a capped log flooded by the product's own automation. // The scan still runs and still returns its full result, which is what those specs // assert on (they read the AJAX payload, not this option), so nothing is weakened for // testing, only the audit trail stops being overwritten by it. $scan_history = get_option( 'csdt_header_scan_history', array() ); if ( ! is_array( $scan_history ) ) { $scan_history = array(); } if ( ! isset( $home_data['error'] ) && ! CSDT_DevTools::viewer_is_test_session() ) { array_unshift( $scan_history, array( 'grade' => $home_data['grade'] ?? '?', 'scanned_at' => time(), 'missing' => $missing_mand ?? 0, 'warnings' => count( $warnings ), ) ); $scan_history = array_slice( $scan_history, 0, 20 ); update_option( 'csdt_header_scan_history', $scan_history, false ); } wp_send_json_success( array( 'home' => $home_data, 'pages' => $page_results, 'scan_history' => $scan_history, ) ); } public static function render_header_scan_panel(): void { $history = get_option( 'csdt_header_scan_history', array() ); if ( ! is_array( $history ) ) { $history = array(); } $latest = $history[0] ?? null; $grade_colors = array( 'A+' => '#15803d', 'A' => '#16a34a', 'B' => '#d97706', 'C' => '#b45309', 'D' => '#dc2626', 'F' => '#991b1b', ); ?> $items ) { if ( 'good' === $bucket || ! is_array( $items ) ) { continue; } $entry['findings'][ $bucket ] = CSDT_Fix_Routes::route_ai_findings( $items ); } } wp_send_json_success( $entry ); } }