kurze * Beschreibung, was beim Deaktivieren blockiert wird. */ const PERMISSIONS_POLICY_FEATURES = array( 'accelerometer' => 'Accelerometer sensor access', 'ambient-light-sensor' => 'Ambient light sensor access', 'attribution-reporting' => 'Attribution Reporting API (ad conversion tracking)', 'autoplay' => 'Automatic audio/video playback', 'battery' => 'Battery status API', 'bluetooth' => 'Web Bluetooth device access', 'browsing-topics' => 'Topics API (interest-based ad targeting)', 'camera' => 'Camera access', 'clipboard-read' => 'Reading from the clipboard', 'clipboard-write' => 'Writing to the clipboard', 'display-capture' => 'Screen/window capture (getDisplayMedia)', 'document-domain' => 'Relaxing document.domain for cross-subdomain access', 'encrypted-media' => 'Encrypted Media Extensions (DRM playback)', 'fullscreen' => 'Entering fullscreen mode', 'gamepad' => 'Gamepad access', 'geolocation' => 'Geolocation access', 'gyroscope' => 'Gyroscope sensor access', 'hid' => 'WebHID device access', 'idle-detection' => 'Detecting user idle state', 'interest-cohort' => 'FLoC / interest cohort tracking', 'local-fonts' => 'Enumerating locally installed fonts', 'magnetometer' => 'Magnetometer sensor access', 'microphone' => 'Microphone access', 'midi' => 'MIDI device access', 'otp-credentials' => 'SMS OTP auto-fill (WebOTP)', 'payment' => 'Payment Request API', 'picture-in-picture' => 'Picture-in-picture video', 'publickey-credentials-get' => 'WebAuthn/Passkey sign-in', 'screen-wake-lock' => 'Preventing the screen from sleeping', 'serial' => 'Web Serial port access', 'speaker-selection' => 'Choosing audio output devices', 'storage-access' => 'Storage Access API (third-party cookies)', 'usb' => 'WebUSB device access', 'web-share' => 'Native OS share sheet', 'window-management' => 'Managing/placing windows across multiple screens', 'xr-spatial-tracking' => 'WebXR (AR/VR) spatial tracking', ); /** Ein Header-Block, wie er fuer beide Kontexte verwendet wird -- Werte werden je Kontext ueberschrieben, siehe *_defaults(). */ private static function header_shape() { return array( 'hsts' => array( 'enabled' => false, 'max_age' => 31536000, // Standard AUS: includeSubDomains sperrt auch Subdomains ohne SSL // (Staging, Mail-Webinterface, Intranet) fuer Browser, die die // Hauptdomain kennen. Wer alle Subdomains per HTTPS betreibt, // schaltet es bewusst ein (Voraussetzung fuer HSTS-Preload). 'include_subdomains' => false, 'preload' => false, ), // Kein echter Header, sondern ein 301-Redirect -- lebt trotzdem hier // (statt in einem eigenen "general"-Bereich), um die bestehende // Save-/Sanitize-/AJAX-Infrastruktur pro Kontext wiederzuverwenden. // Nur im Frontend-Kontext gerendert/ausgewertet (siehe // Wp360_Header_Security_Headers::apply_frontend()) -- Standard AUS, // da ein falsch erkanntes is_ssl() hinter einem Reverse-Proxy/CDN // eine Redirect-Schleife verursachen kann. 'force_https_redirect' => array( 'enabled' => false ), // Ebenfalls kein eigener Header, sondern ein zusaetzlicher, // vom Webserver selbst gesetzter Spiegel ALLER aktivierten // Frontend-Header in der .htaccess (siehe class-htaccess.php) -- // damit sie auch Aufrufe erreichen, die ein Seiten-Cache als // statische Datei direkt ausliefert, ohne PHP zu durchlaufen. // Nur im Frontend-Kontext gerendert/ausgewertet, Standard AUS // (opt-in, schreibt sonst ungefragt in die .htaccess). // // Der Schluesselname stammt noch aus der Zeit, als nur die CSP // gespiegelt wurde; er bleibt, damit bestehende Installationen // ihre Einstellung behalten. 'csp_htaccess_mirror' => array( 'enabled' => false ), 'x_frame_options' => array( 'enabled' => false, 'value' => 'SAMEORIGIN', ), // Anti-Clickjacking ueber CSP frame-ancestors -- der moderne Nachfolger // von X-Frame-Options, den Browser bei gleichzeitigem Vorkommen // bevorzugen. Bewusst unabhaengig von der (PRO-)CSP: Ohne scharf // geschaltete Haupt-CSP geht er als eigene, schmale // Content-Security-Policy-Kopfzeile hinaus, sonst wird er in die // Haupt-CSP eingefuegt (siehe Wp360_Header_Security_Headers::apply()). 'frame_ancestors' => array( 'enabled' => false, 'value' => "'self'", ), 'x_content_type_options' => array( 'enabled' => false ), // Kein eigener Header: ergaenzt fehlende Secure-/SameSite-Attribute // an allen Set-Cookie-Kopfzeilen, die PHP sendet (siehe // class-cookies.php). Nur im Frontend-Kontext gerendert, wirkt aber // auf jede PHP-Antwort der Website, auch Login und wp-admin -- Cookies // gelten fuer die ganze Domain. Standard AUS: SameSite=Lax kann // Zahlungs-Rueckspruenge per POST ohne Sitzungs-Cookie ankommen lassen. 'cookie_hardening' => array( 'enabled' => false, 'samesite' => 'Lax', ), 'referrer_policy' => array( 'enabled' => false, 'value' => 'strict-origin-when-cross-origin', ), 'permissions_policy' => array( 'enabled' => false, 'value' => 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), browsing-topics=()', ), 'coop' => array( 'enabled' => false, 'value' => 'same-origin', ), 'corp' => array( 'enabled' => false, 'value' => 'same-origin', ), ); } public static function defaults() { return array( 'frontend' => self::header_shape(), ); } /** * Empfohlene Werte fuer "Quickstart" -- Frontend bekommt den vollen Satz * inkl. CSP (Report-Only, damit nichts sofort blockiert wird), das Backend * bewusst nur die unkritischen Transport-/Anti-Clickjacking-Header. CSP, * Permissions-Policy und COOP/CORP bleiben im Backend aus, weil viele * Plugins dort Popups/Iframes fremder Dienste einbinden (Zahlungsanbieter, * OAuth) -- das laesst sich nicht pauschal vorhersagen. */ public static function quickstart_values() { $values = self::defaults(); $values['frontend']['hsts']['enabled'] = true; $values['frontend']['x_frame_options']['enabled'] = true; // Gleiche Wirkung wie X-Frame-Options: SAMEORIGIN, also kein // zusaetzliches Risiko -- aber von aktuellen Browsern und // Sicherheitsaudits als eigentlicher Clickjacking-Schutz erwartet. $values['frontend']['frame_ancestors']['enabled'] = true; $values['frontend']['x_content_type_options']['enabled'] = true; $values['frontend']['referrer_policy']['enabled'] = true; $values['frontend']['permissions_policy']['enabled'] = true; $values['frontend']['corp']['enabled'] = true; return $values; } public static function get() { $saved = get_option( self::OPTION_KEY, array() ); return self::sanitize( is_array( $saved ) ? $saved : array() ); } /** * Schreibt ueber store() statt direkt per update_option(): So bleiben * Einstellungen, die diese Variante nicht kennt (Backend-Kontext und CSP, * wenn gerade FREE laeuft), beim Quickstart erhalten. */ public static function apply_quickstart() { $values = self::quickstart_values(); self::store( $values ); return $values; } /** Nimmt beliebig geformte (z. B. per JSON aus dem Request dekodierte) Daten entgegen und liefert garantiert vollstaendige, typsichere Settings zurueck -- unbekannte Schluessel werden verworfen. */ public static function sanitize( array $input ) { $defaults = self::defaults(); $clean = array(); foreach ( self::CONTEXTS as $context ) { $src = isset( $input[ $context ] ) && is_array( $input[ $context ] ) ? $input[ $context ] : array(); $clean[ $context ] = self::sanitize_context( $src, $defaults[ $context ] ); } return $clean; } private static function sanitize_context( array $src, array $defaults ) { $out = $defaults; if ( isset( $src['hsts'] ) && is_array( $src['hsts'] ) ) { $out['hsts']['enabled'] = ! empty( $src['hsts']['enabled'] ); $out['hsts']['max_age'] = max( 0, (int) ( $src['hsts']['max_age'] ?? $defaults['hsts']['max_age'] ) ); $out['hsts']['include_subdomains'] = ! empty( $src['hsts']['include_subdomains'] ); $out['hsts']['preload'] = ! empty( $src['hsts']['preload'] ); } if ( isset( $src['x_frame_options'] ) && is_array( $src['x_frame_options'] ) ) { $value = isset( $src['x_frame_options']['value'] ) ? strtoupper( (string) $src['x_frame_options']['value'] ) : $defaults['x_frame_options']['value']; $out['x_frame_options']['enabled'] = ! empty( $src['x_frame_options']['enabled'] ); $out['x_frame_options']['value'] = in_array( $value, array( 'DENY', 'SAMEORIGIN' ), true ) ? $value : $defaults['x_frame_options']['value']; } if ( isset( $src['frame_ancestors'] ) && is_array( $src['frame_ancestors'] ) ) { $value = isset( $src['frame_ancestors']['value'] ) ? (string) $src['frame_ancestors']['value'] : $defaults['frame_ancestors']['value']; $out['frame_ancestors']['enabled'] = ! empty( $src['frame_ancestors']['enabled'] ); $out['frame_ancestors']['value'] = in_array( $value, array( "'self'", "'none'" ), true ) ? $value : $defaults['frame_ancestors']['value']; } if ( isset( $src['cookie_hardening'] ) && is_array( $src['cookie_hardening'] ) ) { $value = isset( $src['cookie_hardening']['samesite'] ) ? ucfirst( strtolower( (string) $src['cookie_hardening']['samesite'] ) ) : $defaults['cookie_hardening']['samesite']; $out['cookie_hardening']['enabled'] = ! empty( $src['cookie_hardening']['enabled'] ); $out['cookie_hardening']['samesite'] = in_array( $value, array( 'Lax', 'Strict' ), true ) ? $value : $defaults['cookie_hardening']['samesite']; } if ( isset( $src['x_content_type_options'] ) && is_array( $src['x_content_type_options'] ) ) { $out['x_content_type_options']['enabled'] = ! empty( $src['x_content_type_options']['enabled'] ); } if ( isset( $src['referrer_policy'] ) && is_array( $src['referrer_policy'] ) ) { $allowed = array( 'no-referrer', 'no-referrer-when-downgrade', 'origin', 'origin-when-cross-origin', 'same-origin', 'strict-origin', 'strict-origin-when-cross-origin', 'unsafe-url' ); $value = isset( $src['referrer_policy']['value'] ) ? sanitize_key( $src['referrer_policy']['value'] ) : $defaults['referrer_policy']['value']; $out['referrer_policy']['enabled'] = ! empty( $src['referrer_policy']['enabled'] ); $out['referrer_policy']['value'] = in_array( $value, $allowed, true ) ? $value : $defaults['referrer_policy']['value']; } if ( isset( $src['permissions_policy'] ) && is_array( $src['permissions_policy'] ) ) { $out['permissions_policy']['enabled'] = ! empty( $src['permissions_policy']['enabled'] ); $out['permissions_policy']['value'] = isset( $src['permissions_policy']['value'] ) ? self::sanitize_header_value( $src['permissions_policy']['value'] ) : $defaults['permissions_policy']['value']; } if ( isset( $src['force_https_redirect'] ) && is_array( $src['force_https_redirect'] ) ) { $out['force_https_redirect']['enabled'] = ! empty( $src['force_https_redirect']['enabled'] ); } if ( isset( $src['csp_htaccess_mirror'] ) && is_array( $src['csp_htaccess_mirror'] ) ) { $out['csp_htaccess_mirror']['enabled'] = ! empty( $src['csp_htaccess_mirror']['enabled'] ); } foreach ( array( 'coop', 'corp' ) as $key ) { if ( isset( $src[ $key ] ) && is_array( $src[ $key ] ) ) { $allowed = $key === 'coop' ? array( 'same-origin', 'same-origin-allow-popups', 'unsafe-none' ) : array( 'same-origin', 'same-site', 'cross-origin' ); $value = isset( $src[ $key ]['value'] ) ? sanitize_key( $src[ $key ]['value'] ) : $defaults[ $key ]['value']; $out[ $key ]['enabled'] = ! empty( $src[ $key ]['enabled'] ); $out[ $key ]['value'] = in_array( $value, $allowed, true ) ? $value : $defaults[ $key ]['value']; } } return $out; } /** Freitext-Header-Werte: keine Zeilenumbrueche/Steuerzeichen (Header-Injection), sonst weitgehend frei. */ private static function sanitize_header_value( $value ) { $value = (string) $value; $value = preg_replace( '/[\r\n\x00-\x1F]+/', ' ', $value ); return trim( wp_strip_all_tags( $value ) ); } /** * Zerlegt einen gespeicherten Permissions-Policy-Wert in (a) die * bekannten Features, die als "feature=()" (vollstaendig blockiert) * enthalten sind, und (b) alles andere unveraendert als Freitext-Rest * (z. B. "fullscreen=(self)" oder ein Feature, das nicht in der * Checkliste ist) -- damit die Checkliste in den Einstellungen nichts * verliert, was schon vorher per Freitext gesetzt war. * * @return array{checked: array, extra: string} */ public static function parse_permissions_policy_value( $value ) { $checked = array(); $parts = array_filter( array_map( 'trim', explode( ',', (string) $value ) ) ); $leftover = array(); foreach ( $parts as $part ) { if ( preg_match( '/^([a-z0-9-]+)=\(\)$/i', $part, $m ) && isset( self::PERMISSIONS_POLICY_FEATURES[ strtolower( $m[1] ) ] ) ) { $checked[] = strtolower( $m[1] ); } else { $leftover[] = $part; } } return array( 'checked' => $checked, 'extra' => implode( ', ', $leftover ), ); } /** Kehrt parse_permissions_policy_value() um -- baut den zu speichernden Header-Wert aus angehakten Features + Freitext-Rest zusammen. */ public static function build_permissions_policy_value( array $checked, $extra ) { $parts = array(); foreach ( $checked as $feature ) { if ( isset( self::PERMISSIONS_POLICY_FEATURES[ $feature ] ) ) { $parts[] = $feature . '=()'; } } $extra = trim( (string) $extra ); if ( $extra !== '' ) { $parts[] = $extra; } return implode( ', ', $parts ); } public static function save( array $input ) { $settings = self::sanitize( $input ); self::store( $settings ); return $settings; } /** * Gespeicherte Schluessel, die diese Variante nicht kennt (der * Backend-Kontext und die CSP-Einstellungen, wenn gerade FREE laeuft), * bleiben beim Speichern unangetastet -- sonst gingen sie bei einem * Wechsel von PRO auf FREE mit dem ersten Speichern verloren. Zusammengefuehrt * wird je Kontext, weil die CSP innerhalb eines Kontexts liegt. * * @param array> $settings Bereits bereinigte Einstellungen. */ private static function store( array $settings ) { $stored = get_option( self::OPTION_KEY, array() ); $merged = is_array( $stored ) ? $stored : array(); foreach ( $settings as $context => $values ) { $previous = isset( $merged[ $context ] ) && is_array( $merged[ $context ] ) ? $merged[ $context ] : array(); $merged[ $context ] = array_merge( array_diff_key( $previous, $values ), $values ); } update_option( self::OPTION_KEY, $merged ); } }