array( 'enabled' => true, 'value' => 'nosniff', ), 'x-frame-options' => array( 'enabled' => true, 'value' => 'SAMEORIGIN', ), 'referrer-policy' => array( 'enabled' => true, 'value' => 'strict-origin-when-cross-origin', ), 'permissions-policy' => array( 'enabled' => true, 'value' => 'camera=(), microphone=(), geolocation=(), payment=()', ), 'strict-transport-security' => array( 'enabled' => true, 'value' => 'max-age=31536000; includeSubDomains', ), ); } if ( ! empty( $config ) ) { $is_https = is_ssl() || ( isset( $_SERVER['HTTP_X_FORWARDED_PROTO'] ) && 'https' === sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_PROTO'] ) ) ) || ( isset( $_SERVER['HTTP_CF_VISITOR'] ) && strpos( sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_VISITOR'] ?? '' ) ), '"https"' ) !== false ); $header_names = array( 'x-content-type-options' => 'X-Content-Type-Options', 'x-frame-options' => 'X-Frame-Options', 'referrer-policy' => 'Referrer-Policy', 'permissions-policy' => 'Permissions-Policy', 'strict-transport-security' => 'Strict-Transport-Security', ); $fixed = array( 'x-content-type-options' => 'nosniff' ); foreach ( $header_names as $key => $name ) { if ( empty( $config[ $key ]['enabled'] ) ) { continue; } if ( 'strict-transport-security' === $key && ! $is_https ) { continue; } $value = $fixed[ $key ] ?? ( $config[ $key ]['value'] ?? '' ); if ( ! $value ) { continue; } header( $name . ': ' . $value ); } } if ( CSDT_Options::get( 'csdt_devtools_csp_enabled' ) === '1' ) { $csp = self::build_csp_header(); if ( $csp ) { $mode = CSDT_Options::get( 'csdt_devtools_csp_mode' ); if ( self::legacy_presets_pending() ) { $mode = 'report_only'; // see legacy_presets_pending(): never enforce a policy that lost sources. } $hdr = $mode === 'report_only' ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy'; $reporting_on = get_option( 'csdt_csp_reporting_enabled', '0' ) === '1'; $report_uri = $reporting_on ? '; report-uri ' . rest_url( 'csdt/v1/csp-report' ) : ''; header( $hdr . ': ' . $csp . $report_uri ); } } } catch ( \Throwable $e ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } /** * Preset keys an older version stored and this version can no longer expand. * * Up to 1.10.841 the panel offered ticked presets (one per third-party service) * and the policy was built from a table of their hosts. That table is gone: the * administrator now lists the sources their site needs in the directives field, * which is the only thing a policy for THEIR site can honestly be built from. * * A site that ticked presets and upgrades still has the keys stored, and nothing * to turn them back into hosts. Enforcing the smaller policy would block services * that were allowed the day before, silently, so while any are pending the header * is sent report-only and the panel says why. Saving the panel clears the state. * * @since 1.10.841 was the last version with presets; this arrived in the release after it. * @return string[] Stored preset keys, empty once the panel has been saved. */ public static function legacy_presets_pending(): array { if ( get_option( 'csdt_csp_presets_migrated', '0' ) === '1' ) { return array(); } $stored = json_decode( (string) get_option( 'csdt_devtools_csp_services', '[]' ), true ); if ( ! is_array( $stored ) ) { return array(); } return array_values( array_filter( array_map( 'sanitize_key', $stored ) ) ); } /** * Host patterns the administrator has entered in the Allowed sources field. * * Read from the saved option only; nothing here is a shipped list. Keywords and * scheme-only sources are skipped, a leading "*." is kept. * * @since 1.10.841 was the last version with presets; this arrived in the release after it. * @return string[] */ public static function allowed_hosts(): array { $hosts = array(); foreach ( preg_split( '/\s+/', (string) get_option( 'csdt_devtools_csp_custom', '' ) ) as $token ) { if ( '' === $token || "'" === $token[0] || ! str_contains( $token, '.' ) ) { continue; } $host = preg_replace( '#^[a-z][a-z0-9+.-]*://#i', '', $token ); $host = strtolower( (string) preg_replace( '#[/:].*$#', '', (string) $host ) ); if ( '' !== $host ) { $hosts[ $host ] = true; } } return array_keys( $hosts ); } private static function build_csp_header(): string { $custom = trim( get_option( 'csdt_devtools_csp_custom', '' ) ); $use_nonces = CSDT_Options::get( 'csdt_csp_nonces_enabled' ) === '1'; $script_src = $use_nonces ? array( "'self'", "'nonce-" . self::get_csp_nonce() . "'", "'strict-dynamic'" ) : array( "'self'", "'unsafe-inline'", "'unsafe-eval'" ); // style-src must NOT use nonces: CSP3 ignores 'unsafe-inline' when a nonce is present, // which breaks all style="" attributes site-wide. Instead, allow the hljs CDN host directly. $d = array( 'default-src' => array( "'self'" ), 'script-src' => $script_src, // script-src-elem (CSP3) is checked independently, always include 'self' so WP's // own scripts (wp-includes, theme JS) are never blocked when a service map adds this. // Always allow 'unsafe-inline' here (never the nonce), mirrors style-src-elem below. // Themes/plugins inject inline script elements after nonce stamping has run, so a // nonce-only script-src-elem blocks them; and per CSP3 a nonce makes browsers IGNORE // 'unsafe-inline' in the same directive, so the two cannot coexist. script-src keeps // its nonce + 'strict-dynamic' hardening; script-src-elem stays permissive for inline. 'script-src-elem' => array( "'self'", "'unsafe-inline'" ), // script-src-attr: explicit 'none' so inline event-handler attributes (onclick="", // onerror="", etc.) are always blocked regardless of the nonce toggle above, without // this, script-src-attr silently falls back to script-src, so disabling nonces (which // adds 'unsafe-inline' to script-src) would also silently re-allow inline handlers. // 'report-sample' asks the browser to include ~40 chars of the actual violating // handler text in reports, which script-src-attr violations otherwise report as a // bare 'inline' blocked-uri with no way to tell what fired it (e.g. our own markup // vs. a third-party widget injecting handlers at runtime). 'script-src-attr' => array( "'none'", "'report-sample'" ), // style-src-elem must also include 'self' so theme stylesheets are never blocked. 'style-src-elem' => array( "'self'", "'unsafe-inline'" ), // No third-party origin here. A public CDN was allowed for as long as the syntax // highlighter's CSS was fetched from one; that stopped being true when highlight.js // was bundled, and nothing in the plugin has loaded from a CDN since, so the entry // only widened the policy. An administrator who genuinely needs one can add it in // the Allowed sources field. 'style-src' => array( "'self'", "'unsafe-inline'" ), // style-src-attr: CSP3 browsers check this separately from style-src. Explicitly // set 'unsafe-inline' so page builders (Elementor, Divi, WPBakery) that inject // style="" attributes never fire violations without needing any service checkbox. 'style-src-attr' => array( "'unsafe-inline'" ), 'img-src' => array( "'self'", 'data:', 'blob:', 'https:' ), 'media-src' => array( "'self'", 'data:', 'blob:' ), 'font-src' => array( "'self'", 'data:' ), 'connect-src' => array( "'self'" ), 'frame-src' => array( "'self'" ), 'object-src' => array( "'none'" ), 'base-uri' => array( "'self'" ), 'form-action' => array( "'self'" ), ); // Merge custom directives INTO the $d array so there are no duplicate directives. // A duplicate directive causes browsers to use the most-restrictive one, breaking 'self'. if ( $custom ) { foreach ( preg_split( '/\r?\n/', trim( $custom ) ) as $line ) { $line = trim( $line ); if ( ! $line ) { continue; } $tokens = preg_split( '/\s+/', $line, 2 ); $cdir = sanitize_key( $tokens[0] ?? '' ); $cvals = isset( $tokens[1] ) ? preg_split( '/\s+/', trim( $tokens[1] ) ) : array(); // Two directives are complete with no value. They used to be dropped here with // every other valueless line, while the panel's own help offered them as examples. if ( $cdir && empty( $cvals ) && in_array( $cdir, array( 'upgrade-insecure-requests', 'block-all-mixed-content' ), true ) ) { $d[ $cdir ] = array(); continue; } if ( ! $cdir || empty( $cvals ) ) { continue; } if ( ! isset( $d[ $cdir ] ) ) { $d[ $cdir ] = array(); } foreach ( $cvals as $cv ) { if ( $cv && ! in_array( $cv, $d[ $cdir ], true ) ) { $d[ $cdir ][] = $cv; } } } } // script-src-elem / style-src-elem (CSP3) OVERRIDE script-src / style-src for // element loads (script elements, stylesheet links) whenever they are present, and // they always are in our base policy. So any host allowed for script-src that is // NOT also in script-src-elem gets blocked at the element level even though // script-src permits it. This bit Cloudflare Insights (beacon.min.js), AdSense // SODAR, and any service map that only lists script-src. Auto-mirror every host // into the *-elem directive so we never have to maintain both by hand. // Copy host/scheme sources ONLY, never keyword tokens: 'strict-dynamic' in a // *-elem directive voids the entire host allowlist (per CSP spec), and a 'nonce-…' // would make 'unsafe-inline' be ignored there, re-breaking inline scripts. foreach ( array( 'script-src' => 'script-src-elem', 'style-src' => 'style-src-elem', ) as $src => $elem ) { if ( empty( $d[ $src ] ) ) { continue; } if ( ! isset( $d[ $elem ] ) ) { $d[ $elem ] = array(); } foreach ( $d[ $src ] as $v ) { if ( $v === '' || $v[0] === "'" ) { continue; } // skip 'self' 'nonce-…' 'strict-dynamic' 'unsafe-*' if ( ! in_array( $v, $d[ $elem ], true ) ) { $d[ $elem ][] = $v; } } } $parts = array(); foreach ( $d as $dir => $vals ) { $parts[] = trim( $dir . ' ' . implode( ' ', $vals ) ); } return implode( '; ', $parts ); } // ── CSP nonce helpers ───────────────────────────────────────────────────── public static function get_csp_nonce(): string { if ( self::$csp_nonce === null ) { self::$csp_nonce = bin2hex( random_bytes( 16 ) ); } return self::$csp_nonce; } public static function csp_nonce_script_tag( string $tag ): string { return self::add_script_nonces( $tag ); } /** * Sets the page nonce on every script element in $html that has none. * * A tag processor, not a pattern: a pattern also matches the text of a script opening tag * inside a JavaScript string or an HTML comment, where adding an attribute corrupts the page. * * @since 1.10.878 * @param string $html Markup to update. * @return string */ private static function add_script_nonces( string $html ): string { $nonce = self::get_csp_nonce(); if ( ! $nonce || ! class_exists( 'WP_HTML_Tag_Processor' ) ) { return $html; } $processor = new WP_HTML_Tag_Processor( $html ); while ( $processor->next_tag( array( 'tag_name' => 'SCRIPT' ) ) ) { if ( null === $processor->get_attribute( 'nonce' ) ) { $processor->set_attribute( 'nonce', $nonce ); } } return $processor->get_updated_html(); } /** @param array $attrs */ public static function csp_nonce_inline_attrs( array $attrs ): array { $attrs['nonce'] = self::get_csp_nonce(); return $attrs; } /** * Adds the page nonce to every script element that does not already carry one. * * Catches AdSense, theme inline scripts and anything else that bypasses * wp_enqueue_scripts, the two filters above only see what went through WordPress. * * WHY THIS IS A FILTER AND NOT AN OUTPUT BUFFER * -------------------------------------------- * It used to open its own output buffer with a callback, closed on `shutdown`. * The plugin directory's rule is that a buffer must be closed inside the same function * that opened it, and its guidance names this filter, added in * WordPress 6.9, as the sanctioned way to modify a whole response. Merely attaching a * callback here makes core start and own the buffer, so the plugin no longer opens one. * * Core's buffer is also better scoped than the old `template_redirect` hook: it runs only * for HTML content types, never for REST, feeds or admin, which is exactly the set of * responses a nonce belongs in. * * WP_HTML_Tag_Processor rather than a regular expression, per core's own guidance for this * filter, and because the previous pattern matched the text of a script opening tag wherever it * appeared, including inside a JavaScript string or an HTML comment, where injecting an * attribute corrupts the page. * * Never open a buffer or emit output from here: core treats either as a fatal. * * @since 1.10.691 * @param string $html The full response HTML, supplied by core. * @return string */ public static function csp_filter_template_nonces( $html ): string { return self::add_script_nonces( (string) $html ); } public static function render_csp_panel(): void { $csp_on = CSDT_Options::get( 'csdt_devtools_csp_enabled' ) === '1'; $csp_mode = CSDT_Options::get( 'csdt_devtools_csp_mode' ); // CSP is off by default. While it is off the mode sends nothing, so the form offers // Report-Only first: whoever switches CSP on starts by reporting, as the warning says. // A site with CSP on keeps the mode it has; nothing live changes (2026-10-28). if ( ! $csp_on ) { $csp_mode = 'report_only'; } $reporting_on = get_option( 'csdt_csp_reporting_enabled', '0' ) === '1'; $legacy_presets = self::legacy_presets_pending(); $csp_custom = get_option( 'csdt_devtools_csp_custom', '' ); $csp_backup = json_decode( get_option( 'csdt_devtools_csp_backup', '' ), true ); $backup_time = is_array( $csp_backup ) ? ( $csp_backup['saved_at'] ?? 0 ) : 0; $csp_history = json_decode( get_option( 'csdt_csp_history', '[]' ), true ); if ( ! is_array( $csp_history ) ) { $csp_history = array(); } $fixes_log = json_decode( get_option( 'csdt_csp_fixes_log', '[]' ), true ); if ( ! is_array( $fixes_log ) ) { $fixes_log = array(); } $stored_violations = json_decode( get_option( 'csdt_csp_violations', '[]' ), true ); $viol_count = is_array( $stored_violations ) ? count( $stored_violations ) : 0; ?>
CSDT_Options::get( 'csdt_devtools_csp_enabled' ), 'mode' => CSDT_Options::get( 'csdt_devtools_csp_mode' ), 'custom' => get_option( 'csdt_devtools_csp_custom', '' ), 'reporting_enabled' => get_option( 'csdt_csp_reporting_enabled', '0' ), ); $new = array( 'enabled' => $enabled, 'mode' => $mode, 'custom' => $custom, 'reporting_enabled' => $reporting_enabled, ); $new_entry = self::append_csp_history( array( 'enabled' => $old['enabled'], 'mode' => $old['mode'], 'custom' => $old['custom'], 'reporting_enabled' => $old['reporting_enabled'], 'saved_at' => time(), 'label' => self::csp_history_label( $old, $new ), ) ); // Single-step rollback backup (legacy). update_option( 'csdt_devtools_csp_backup', wp_json_encode( array_merge( $old, array( 'saved_at' => time() ) ) ) ); update_option( 'csdt_devtools_csp_enabled', $enabled === '1' ? '1' : '0' ); update_option( 'csdt_devtools_csp_mode', in_array( $mode, array( 'enforce', 'report_only' ), true ) ? $mode : 'enforce' ); update_option( 'csdt_devtools_csp_custom', $custom ); // The administrator has now seen the panel without presets and saved what they // want, so the stored preset keys stop forcing Report-Only. See legacy_presets_pending(). update_option( 'csdt_devtools_csp_services', '[]' ); update_option( 'csdt_csp_presets_migrated', '1', false ); update_option( 'csdt_csp_reporting_enabled', $reporting_enabled === '1' ? '1' : '0' ); wp_send_json_success( array( 'has_backup' => true, 'history_entry' => array( 'label' => $new_entry['label'], 'saved_at' => $new_entry['saved_at'], ), ) ); } catch ( \Throwable $e ) { CSDT_Ajax::fail( $e, __METHOD__ ); } } private static function csp_history_label( array $old, array $new ): string { $labels = array(); if ( $old['enabled'] !== $new['enabled'] ) { $labels[] = $new['enabled'] === '1' ? 'CSP enabled' : 'CSP disabled'; } if ( $old['mode'] !== $new['mode'] ) { $labels[] = $new['mode'] === 'report_only' ? 'Switched to report-only' : 'Switched to enforce'; } if ( trim( $old['custom'] ?? '' ) !== trim( $new['custom'] ?? '' ) ) { $labels[] = 'Allowed sources updated'; } return $labels ? implode( '; ', $labels ) : 'Settings saved'; } /** * True when a saved snapshot depends on presets this version cannot expand. * * History and rollback entries written up to 1.10.841 carry the ticked preset * keys beside the directives text. Restoring one now would bring back the text * and none of the hosts the presets stood for, which is a smaller policy than * the one the label promises, so it is refused rather than half done. * * @since 1.10.841 was the last version with presets; this arrived in the release after it. * @param array $entry One history or backup entry. * @return bool */ private static function snapshot_needs_presets( array $entry ): bool { $svcs = $entry['services'] ?? '[]'; if ( is_string( $svcs ) ) { $svcs = json_decode( $svcs, true ); } return is_array( $svcs ) && ! empty( $svcs ); } public static function ajax_csp_restore(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } try { $idx = isset( $_POST['index'] ) ? absint( wp_unslash( $_POST['index'] ) ) : -1; $history = json_decode( get_option( 'csdt_csp_history', '[]' ), true ); if ( ! is_array( $history ) || ! isset( $history[ $idx ] ) ) { wp_send_json_error( 'History entry not found.' ); } $entry = $history[ $idx ]; if ( self::snapshot_needs_presets( $entry ) ) { wp_send_json_error( 'This snapshot was saved when service presets existed. Its hosts cannot be rebuilt, so it cannot be restored.' ); } // Push current live state to the top of history before restoring. self::append_csp_history( array( 'enabled' => CSDT_Options::get( 'csdt_devtools_csp_enabled' ), 'mode' => CSDT_Options::get( 'csdt_devtools_csp_mode' ), 'custom' => get_option( 'csdt_devtools_csp_custom', '' ), 'reporting_enabled' => get_option( 'csdt_csp_reporting_enabled', '0' ), 'saved_at' => time(), 'label' => 'Before restore to: ' . ( $entry['label'] ?? 'previous state' ), ) ); update_option( 'csdt_devtools_csp_enabled', $entry['enabled'] ?? '0' ); update_option( 'csdt_devtools_csp_mode', $entry['mode'] ?? 'enforce' ); update_option( 'csdt_devtools_csp_custom', $entry['custom'] ?? '' ); update_option( 'csdt_csp_reporting_enabled', $entry['reporting_enabled'] ?? '0' ); wp_send_json_success( array( 'enabled' => $entry['enabled'] ?? '0', 'mode' => $entry['mode'] ?? 'enforce', 'custom' => $entry['custom'] ?? '', 'reporting_enabled' => $entry['reporting_enabled'] ?? '0', ) ); } catch ( \Throwable $e ) { CSDT_Ajax::fail( $e, __METHOD__ ); } } public static function ajax_csp_rollback(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } try { $raw = get_option( 'csdt_devtools_csp_backup', '' ); if ( ! $raw ) { wp_send_json_error( 'No backup available' ); } $backup = json_decode( $raw, true ); if ( ! is_array( $backup ) ) { wp_send_json_error( 'Backup corrupt' ); } if ( self::snapshot_needs_presets( $backup ) ) { wp_send_json_error( 'This backup was saved when service presets existed. Its hosts cannot be rebuilt, so it cannot be restored.' ); } update_option( 'csdt_devtools_csp_enabled', $backup['enabled'] ?? '0' ); update_option( 'csdt_devtools_csp_mode', $backup['mode'] ?? 'enforce' ); update_option( 'csdt_devtools_csp_custom', $backup['custom'] ?? '' ); delete_option( 'csdt_devtools_csp_backup' ); wp_send_json_success( array( 'enabled' => $backup['enabled'] ?? '0', 'mode' => $backup['mode'] ?? 'enforce', 'custom' => $backup['custom'] ?? '', ) ); } catch ( \Throwable $e ) { CSDT_Ajax::fail( $e, __METHOD__ ); } } public static function register_csp_report_route(): void { // The policy names this endpoint only while violation logging is on, so it is not // registered at all otherwise: no open route on a site that never asked for one. if ( get_option( 'csdt_csp_reporting_enabled', '0' ) !== '1' ) { return; } try { register_rest_route( 'csdt/v1', '/csp-report', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'rest_csp_report' ), // Browsers send CSP violation reports without credentials, so no nonce or // login can gate this. The gate is the per-address cap on stored reports. 'permission_callback' => array( __CLASS__, 'csp_report_allowed' ), ) ); } catch ( \Throwable $e ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } /** Max stored length for any single string field in a CSP report entry. */ const CSP_FIELD_MAX = 512; /** Max reports one client IP may persist per throttle window. */ const CSP_REPORT_MAX_PER_WINDOW = 20; /** Throttle window (seconds) for the per-IP report cap. */ const CSP_REPORT_WINDOW = 600; /** * Permission check for a violation report: refuses an address that is over its cap. * * The client IP is the proxy-validated one, not raw REMOTE_ADDR, which behind a * proxy is the edge and would put every visitor in one bucket. Browsers ignore the * answer to a report, so a refusal changes nothing a visitor can see. * * @return true|\WP_Error */ public static function csp_report_allowed() { try { $count = (int) get_transient( 'csdt_csp_rl_' . md5( (string) CSDT_DevTools::get_client_ip() ) ); if ( $count >= self::CSP_REPORT_MAX_PER_WINDOW ) { return new \WP_Error( 'csdt_csp_report_throttled', __( 'Too many reports.', 'cloudscale-cyber-devtools' ), array( 'status' => 429 ) ); } } catch ( \Throwable $e ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } return true; } public static function rest_csp_report( \WP_REST_Request $request ): \WP_REST_Response { try { // The per-address cap is enforced by csp_report_allowed() before this runs. // Read again here only to add this report to the count once it is stored. $rl_key = 'csdt_csp_rl_' . md5( (string) CSDT_DevTools::get_client_ip() ); $rl_count = (int) get_transient( $rl_key ); $body = json_decode( $request->get_body(), true ); $report = $body['csp-report'] ?? null; if ( ! is_array( $report ) ) { return new \WP_REST_Response( null, 204 ); } // Truncate every free-text field so one hostile report can't bloat the // (100-entry-capped) option with megabytes of attacker-supplied string. $clip = static function ( $v ): string { return mb_substr( (string) $v, 0, self::CSP_FIELD_MAX ); }; $entry = array( 'time' => time(), 'blocked' => isset( $report['blocked-uri'] ) ? $clip( $report['blocked-uri'] ) : '', 'directive' => isset( $report['violated-directive'] ) ? $clip( $report['violated-directive'] ) : '', 'page' => isset( $report['document-uri'] ) ? $clip( $report['document-uri'] ) : '', 'source' => isset( $report['source-file'] ) ? $clip( $report['source-file'] ) : '', 'line' => isset( $report['line-number'] ) ? (int) $report['line-number'] : 0, ); // Skip truly empty reports; keep eval/inline violations, they surface plugin issues if ( $entry['blocked'] === '' && $entry['directive'] === '' ) { return new \WP_REST_Response( null, 204 ); } // Drop browser-extension/browser-internal noise BEFORE storing, keeps // the violations history readable and prevents alert spam. if ( self::is_noise_report( $entry ) ) { return new \WP_REST_Response( null, 204 ); } // Count this stored report against the per-IP window. set_transient( $rl_key, $rl_count + 1, self::CSP_REPORT_WINDOW ); $stored = json_decode( get_option( 'csdt_csp_violations', '[]' ), true ); if ( ! is_array( $stored ) ) { $stored = array(); } array_unshift( $stored, $entry ); update_option( 'csdt_csp_violations', wp_json_encode( array_slice( $stored, 0, 100 ) ), false ); self::send_csp_alert( $entry ); return new \WP_REST_Response( null, 204 ); } catch ( \Throwable $e ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); return new \WP_Error( 'internal_error', 'Internal server error', array( 'status' => 500 ) ); } } /** * Decide whether a CSP report is browser-extension or browser-internal * noise that we should drop rather than store/alert. * * Suppression patterns: * 1. Source/blocked URLs with extension URI schemes (chrome-extension://, etc.) * 2. Bare extension labels Chrome/Firefox sometimes emit ("chrome-extension", * "moz-extension", "user-script") with no scheme. * 3. The reporting page is a browser-internal URL (about, about:, chrome://, * internal://local/ Firefox reader mode, etc.) * 3b. source-file is bare "about", extension inline injection marker. Real page * scripts always report the page URL as source-file; bare "about" only appears * when a script executes in an about:blank context (extensions, reader mode). * 4. The reporting page's host doesn't match this site's host, catches * reports from cPanel webmail (:2082), proxy previews, etc. * 4b. The reporting page uses HTTP while the site is HTTPS-only. Indicates direct * server/port access bypassing Cloudflare, not real visitor traffic. * 5. The blocked URL's host is a known extension CDN (configurable via * the csdt_csp_noise_hosts option, with built-in defaults). * 5b. Browser-initiated Google Translate injections. */ private static function is_noise_report( array $entry ): bool { $blocked = (string) ( $entry['blocked'] ?? '' ); $source = (string) ( $entry['source'] ?? '' ); $page = (string) ( $entry['page'] ?? '' ); // (0) Bare 'inline' / 'eval' keyword tokens. With 'unsafe-inline' allowed in // script-src-elem / style-src-* these are config-level noise, not host-based // attacks, browsers still emit a report in report-only mode or from directives // that don't carry 'unsafe-inline'. Suppress so they don't spam the log/Telegram. if ( in_array( strtolower( $blocked ), array( 'inline', 'eval', 'wasm-eval', 'unsafe-eval' ), true ) ) { return true; } // (1) Full extension URI schemes $noise_prefixes = array( 'moz-extension://', 'chrome-extension://', 'safari-extension://', 'safari-web-extension://', 'webkit-masked-url://', ); foreach ( $noise_prefixes as $p ) { if ( str_starts_with( $blocked, $p ) || str_starts_with( $source, $p ) ) { return true; } } // (2) Bare extension labels (no scheme) $bare_labels = array( 'chrome-extension', 'moz-extension', 'safari-extension', 'safari-web-extension', 'webkit-masked-url', 'user-script', 'extension', ); if ( $source !== '' && in_array( $source, $bare_labels, true ) ) { return true; } // (3) Browser-internal "pages" that aren't real URLs on this site $internal_page_prefixes = array( 'about:', 'data:', 'chrome://', 'chrome-extension://', 'view-source:', 'file://', 'internal://', ); if ( $page === 'about' ) { return true; } foreach ( $internal_page_prefixes as $p ) { if ( str_starts_with( $page, $p ) ) { return true; } } // (3b) source-file is bare "about", extension/reader-mode inline injection. // Real inline scripts always report source-file as the page URL. Bare "about" // only appears when execution happened in an about:blank context (extensions). if ( $source === 'about' ) { return true; } // (4) Reporting page hostname doesn't match this site (cPanel :2082, proxies, etc.) $site_url = get_site_url(); $site_host = wp_parse_url( $site_url, PHP_URL_HOST ); if ( $site_host && str_starts_with( $page, 'http' ) ) { $page_host = wp_parse_url( $page, PHP_URL_HOST ); if ( $page_host && strcasecmp( $page_host, $site_host ) !== 0 ) { return true; } } // (4b) Non-HTTPS page when the site is HTTPS-only. Reports from http:// indicate // direct server/port access (e.g. :8080) that bypasses Cloudflare, not real traffic. if ( str_starts_with( $site_url, 'https://' ) && str_starts_with( $page, 'http://' ) ) { return true; } // (5b) Browser-initiated page translation. It fires for every visitor whose browser // translates the page, and the site owner can neither allow nor prevent it, so it is // dropped rather than alerted on. Recognised by the path the translator injects // under, on any host, so no host is named here. A translator that reports under some // other shape can be silenced from the noise-hosts field below. if ( str_starts_with( (string) wp_parse_url( $blocked, PHP_URL_PATH ), '/_/translate_http/' ) ) { return true; } // (5) Known extension CDN hosts in the blocked URL, admin-configurable $noise_hosts = self::get_csp_noise_hosts(); if ( $blocked !== '' && str_starts_with( $blocked, 'http' ) ) { $blocked_host = wp_parse_url( $blocked, PHP_URL_HOST ); if ( $blocked_host ) { foreach ( $noise_hosts as $h ) { $h = trim( strtolower( (string) $h ) ); if ( $h === '' ) { continue; } if ( strcasecmp( $blocked_host, $h ) === 0 ) { return true; } // Match a trailing-dot suffix as a wildcard, e.g. ".perplexity.ai" if ( str_starts_with( $h, '.' ) && str_ends_with( strtolower( $blocked_host ), $h ) ) { return true; } } } } return false; } /** * Returns the merged list of extension/CDN hostnames whose CSP reports * we suppress as noise. Built-in defaults plus an admin-managed option. */ private static function get_csp_noise_hosts(): array { // No built-in defaults. Noise is specific to a site and its visitors' browsers, so the // one site that sees it lists it in the admin field; the plugin names no host. $defaults = array(); $extra = get_option( 'csdt_csp_noise_hosts', array() ); if ( is_string( $extra ) ) { $extra = array_filter( array_map( 'trim', preg_split( '/[\s,]+/', $extra ) ?: array() ) ); } if ( ! is_array( $extra ) ) { $extra = array(); } return array_values( array_unique( array_merge( $defaults, $extra ) ) ); } /** * Push a Telegram alert for a CSP violation worth a human's attention. * * Silent in Report-Only mode, violations there are expected during setup. * Throttles to one alert per directive+blocked per 24 hours in Enforce mode. */ private static function send_csp_alert( array $entry ): void { if ( ! CSDT_Telegram::is_configured() ) { return; } // Report-Only mode is for testing. Violations are expected and don't need // Telegram noise, admins review the violation log manually at their own pace. if ( CSDT_Options::get( 'csdt_devtools_csp_mode' ) === 'report_only' ) { return; } $blocked = (string) ( $entry['blocked'] ?? '' ); // Throttle: one alert per directive+host per 24 hours. // Font and style CDNs serve many uniquely-hashed paths per typeface; normalise // to scheme://host so every font file from the same CDN shares one bucket. $dedup_url = $blocked; if ( str_starts_with( $blocked, 'http' ) ) { $parsed = wp_parse_url( $blocked ); if ( ! empty( $parsed['host'] ) ) { $dedup_url = ( $parsed['scheme'] ?? 'https' ) . '://' . $parsed['host']; } } $dedup_key = 'csdt_csp_tg_' . md5( ( $entry['directive'] ?? '' ) . '|' . $dedup_url ); if ( get_transient( $dedup_key ) ) { return; } set_transient( $dedup_key, 1, DAY_IN_SECONDS ); $body = sprintf( "directive: %s\nblocked: %s\npage: %s", $entry['directive'] ?? '', $blocked !== '' ? $blocked : '(inline)', $entry['page'] ?? '' ); CSDT_Alert_Events::send( CSDT_Alert_Events::SECURITY, "CSP violation\n\n" . $body, 'CloudScale Cyber Devtools', 'info' ); } public static function ajax_csp_violations_get(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } try { $stored = json_decode( get_option( 'csdt_csp_violations', '[]' ), true ); wp_send_json_success( is_array( $stored ) ? $stored : array() ); } catch ( \Throwable $e ) { CSDT_Ajax::fail( $e, __METHOD__ ); } } public static function ajax_csp_violations_clear(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } try { delete_option( 'csdt_csp_violations' ); wp_send_json_success(); } catch ( \Throwable $e ) { CSDT_Ajax::fail( $e, __METHOD__ ); } } public static function ajax_csp_fixes_get(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } try { $stored = json_decode( get_option( 'csdt_csp_fixes_log', '[]' ), true ); wp_send_json_success( is_array( $stored ) ? $stored : array() ); } catch ( \Throwable $e ) { CSDT_Ajax::fail( $e, __METHOD__ ); } } public static function ajax_csp_fixes_clear(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } try { delete_option( 'csdt_csp_fixes_log' ); wp_send_json_success(); } catch ( \Throwable $e ) { CSDT_Ajax::fail( $e, __METHOD__ ); } } public static function ajax_csp_apply_fix(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } try { $type = isset( $_POST['type'] ) ? sanitize_key( wp_unslash( $_POST['type'] ) ) : ''; $value = isset( $_POST['value'] ) ? sanitize_text_field( wp_unslash( $_POST['value'] ) ) : ''; $directive = isset( $_POST['directive'] ) ? sanitize_key( wp_unslash( $_POST['directive'] ) ) : 'script-src'; if ( 'custom' !== $type || ! $value ) { wp_send_json_error( 'Invalid parameters.' ); } // Snapshot current state for history before any changes. $old_enabled = CSDT_Options::get( 'csdt_devtools_csp_enabled' ); $old_mode = CSDT_Options::get( 'csdt_devtools_csp_mode' ); $old_custom = get_option( 'csdt_devtools_csp_custom', '' ); $old_reporting = get_option( 'csdt_csp_reporting_enabled', '0' ); // Allow CSP keyword values (quoted) OR valid origins (https://). $is_csp_keyword = preg_match( "/^'(unsafe-inline|unsafe-eval|unsafe-hashes|strict-dynamic|nonce-[a-zA-Z0-9+\/=]+|sha256-[a-zA-Z0-9+\/=]+|sha384-[a-zA-Z0-9+\/=]+|sha512-[a-zA-Z0-9+\/=]+|wasm-unsafe-eval|self|none)'$/", $value ); $is_valid_origin = preg_match( '/^(https?:)?\/\//', $value ); if ( ! $is_csp_keyword && ! $is_valid_origin ) { wp_send_json_error( 'Invalid CSP value, must be a URL origin (https://example.com) or a CSP keyword (\'unsafe-inline\').' ); } // Escape URL origins, use raw value for CSP keywords. $origin = $is_csp_keyword ? $value : esc_url_raw( $value ); // Add the source to the line for ITS directive. The test used to be whether the // origin appeared anywhere in the field, so a host already allowed for images was // reported as applied when the script load that raised the violation still was not. $lines = preg_split( '/\r?\n/', trim( (string) $old_custom ) ); $done = false; foreach ( $lines as $i => $line ) { $tokens = preg_split( '/\s+/', trim( $line ) ); if ( sanitize_key( $tokens[0] ?? '' ) !== $directive ) { continue; } if ( ! in_array( $origin, $tokens, true ) ) { $lines[ $i ] = trim( $line ) . ' ' . $origin; } $done = true; break; } if ( ! $done ) { $lines[] = $directive . ' ' . $origin; } $new_custom = implode( "\n", array_filter( array_map( 'trim', $lines ), 'strlen' ) ); $fix_label = 'Fix applied: Added ' . $origin . ' to allowed sources (' . $directive . ')'; update_option( 'csdt_devtools_csp_custom', $new_custom ); self::append_csp_history( array( 'enabled' => $old_enabled, 'mode' => $old_mode, 'custom' => $old_custom, 'reporting_enabled' => $old_reporting, 'saved_at' => time(), 'label' => $fix_label, ) ); self::append_fixes_log( array( 'time' => time(), 'label' => $fix_label, ) ); wp_send_json_success( array( 'custom' => $new_custom, 'label' => $fix_label, ) ); } catch ( \Throwable $e ) { CSDT_Ajax::fail( $e, __METHOD__ ); } } }