self::htaccess_path(), 'writable' => self::htaccess_writable(), 'is_nginx' => self::is_nginx(), 'block_present' => self::current_block_lines() !== array(), 'last_written' => (int) get_option( self::OPTION_LAST_WRITTEN, 0 ), 'backup_available' => ! empty( $backup['content'] ), 'backup_time' => (int) ( $backup['time'] ?? 0 ), 'last_test' => is_array( $last_test ) ? $last_test : array(), ); } private static function current_block_lines() { require_once ABSPATH . 'wp-admin/includes/misc.php'; $path = self::htaccess_path(); if ( ! is_file( $path ) ) { return array(); } return (array) extract_from_markers( $path, self::MARKER ); } /** * Baut den Regelblock aus allen aktivierten Frontend-Headern -- Werte und * Bedingungen stammen aus derselben Quelle wie der PHP-Versand * (Wp360_Header_Security_Headers::static_header_values() bzw. build_csp()), * damit der vom Webserver gesetzte Header nie von dem abweicht, was PHP * fuer nicht gecachte Aufrufe sendet. Nur aktiv, wenn der Spiegel-Schalter * an ist -- sonst leerer Block (entfernt einen zuvor geschriebenen). * * Die CSP-Zeile kommt nur mit aktiver PRO-Lizenz dazu; die uebrigen Header * gibt es auch im FREE-Build, denn ein Seiten-Cache laesst sonst genauso * HSTS, X-Frame-Options & Co. verschwinden. */ public static function build_block( array $settings ) { $config = $settings['frontend'] ?? array(); if ( empty( $config['csp_htaccess_mirror']['enabled'] ) ) { return array(); } $headers = Wp360_Header_Security_Headers::static_header_values( $config, array( // Der Webserver kennt weder die Customizer-Vorschau noch das // Schema der einzelnen Anfrage: Die Vorschau wird unten per // Umgebungsvariable ausgenommen, HSTS ueber http ignoriert // jeder Browser ohnehin (RFC 6797), es zaehlt also nur, ob die // Website selbst auf https laeuft. 'in_customizer_preview' => false, 'is_ssl' => 'https' === wp_parse_url( home_url( '/' ), PHP_URL_SCHEME ), ) ); // Die schmale frame-ancestors-Zeile (ohne scharfe Haupt-CSP) ergaenzt // eine vom Hoster gesetzte CSP nur -- sie darf sie nicht per "unset" // entfernen, sonst verloere eine gecachte Seite deren script-src & Co. $standalone_csp = $headers['Content-Security-Policy'] ?? null; if ( array() === $headers ) { return array(); } $lines = array(); // Leerer Pfad waere als Regex "^" und wuerde auf jede Anfrage passen -- // dann traege der Block ueberhaupt keinen Header mehr ein. $admin_path = (string) wp_parse_url( admin_url( '/' ), PHP_URL_PATH ); if ( '' === $admin_path ) { $admin_path = '/wp-admin/'; } $lines[] = ''; // Die .htaccess im Stammverzeichnis gilt auch fuer wp-admin. Dort hat // das Plugin eine eigene Konfiguration (getrennte Backend-Header) -- // der Frontend-Block darf ihr nicht dazwischenfunken. Pfad aus // admin_url(), nicht fest "/wp-admin/": WordPress kann in einem // Unterverzeichnis liegen oder das Backend umbenannt haben. $lines[] = ' SetEnvIf Request_URI "^' . self::escape_value( preg_quote( $admin_path ) ) . '" WP360_HS_SKIP'; // Die Live-Vorschau des Customizers ist technisch eine Frontend-URL in // einem iFrame des Backends -- X-Frame-Options: DENY oder eine enge // frame-ancestors-Direktive wuerden sie zerstoeren. PHP nimmt sie // bereits aus (siehe class-headers.php), der Webserver muss es hier // eigenstaendig nachbilden. $lines[] = ' SetEnvIf Query_String "customize_changeset_uuid=" WP360_HS_SKIP'; $lines[] = ''; $lines[] = ''; foreach ( $headers as $name => $value ) { // Erst entfernen, dann setzen -- beides nur ausserhalb des // Backends. // // Das "unset" braucht es, weil manche Hoster eigene // Sicherheits-Header serverweit vorgeben (etwa eine eigene CSP). // Diese liegen in einer zweiten Header-Tabelle, die der Webserver // zusaetzlich ausliefert: Eine gecachte Seite truege sonst beide // Kopfzeilen, und bei einer CSP wendet der Browser beide // gemeinsam an -- es gaelte faktisch die fremde, strengere // Richtlinie, die hier eingestellte liefe ins Leere. // // "setifempty" statt "always set": Letzteres schreibt ebenfalls in // jene zweite Tabelle und liefert den Header dann ZUSAETZLICH zu // dem von PHP gesendeten aus (mit Apache 2.4 nachgemessen). // "setifempty" setzt ihn nur, wenn ihn noch niemand gesetzt hat: // genau der Fall einer gecachten Seite, die der Webserver ohne // PHP ausliefert. // // Ausnahme: die eigenstaendige frame-ancestors-Zeile. Ohne "unset" // bleibt eine Hoster-CSP bestehen und der Browser wendet beide // gemeinsam an -- genau wie bei nicht gecachten Aufrufen, bei denen // PHP die Zeile zusaetzlich zur Hoster-CSP sendet. if ( 'Content-Security-Policy' !== $name || $value !== $standalone_csp ) { $lines[] = ' Header always unset ' . $name . ' env=!WP360_HS_SKIP'; } $lines[] = ' Header setifempty ' . $name . ' "' . self::escape_value( $value, true ) . '" env=!WP360_HS_SKIP'; } $lines[] = ''; return $lines; } /** * Macht einen Header-Wert fuer eine Apache-Direktive sicher: Zeilenumbrueche * wuerden die Konfigurationszeile aufbrechen und werden entfernt, * Anfuehrungszeichen werden maskiert. Letztere kommen regulaer vor -- * Reporting-Endpoints schreibt die Melde-URL in Anfuehrungszeichen. */ private static function escape_value( $value, $is_header_format = false ) { $value = str_replace( array( "\r", "\n", "\0" ), '', (string) $value ); // Zuerst den Backslash, sonst wuerde das Maskierungszeichen der // Anfuehrungszeichen selbst verdoppelt. $value = str_replace( '\\', '\\\\', $value ); if ( $is_header_format ) { // mod_headers wertet "%" als Formatangabe (%{VAR}e); ein einzelnes // "%" mit unbekanntem Zeichen waere ein Konfigurationsfehler. $value = str_replace( '%', '%%', $value ); } return str_replace( '"', '\"', $value ); } /** * @return array{written: bool, test: array} */ public static function apply( array $settings ) { if ( ! self::current_user_may_write() ) { return array( 'written' => false, 'test' => array( 'ok' => false, 'message' => __( 'On a multisite network only a super admin can change the .htaccess file.', '360-orbit-header-security' ), ), ); } return self::write_block( self::build_block( $settings ) ); } private static function write_block( array $lines ) { if ( ! self::htaccess_writable() ) { return array( 'written' => false, 'test' => array( 'ok' => false, 'message' => __( 'The .htaccess file is not writable. Please adjust file permissions or edit it manually.', '360-orbit-header-security' ), ), ); } require_once ABSPATH . 'wp-admin/includes/misc.php'; $previous = self::current_block_lines(); if ( $previous === $lines ) { // Unveraendert -- kein Schreiben, kein neuer Selbsttest noetig. Wp360_Header_Security_Mu_Sentinel::sync( $lines ); return array( 'written' => false, 'test' => get_option( self::OPTION_LAST_TEST, array( 'ok' => true ) ), ); } update_option( self::OPTION_BACKUP, array( 'content' => $previous, 'time' => time(), ), false ); $path = self::htaccess_path(); self::insert_block( $path, $lines ); update_option( self::OPTION_LAST_WRITTEN, time(), false ); $test = self::self_test(); update_option( self::OPTION_LAST_TEST, $test, false ); if ( ! $test['ok'] ) { self::insert_block( $path, $previous ); $test['rolled_back'] = true; } // Der Waechter existiert nur, solange ein Block in der .htaccess steht. Wp360_Header_Security_Mu_Sentinel::sync( $test['ok'] ? $lines : $previous ); return array( 'written' => true, 'test' => $test, ); } public static function restore_backup() { if ( ! self::current_user_may_write() ) { return array( 'ok' => false, 'message' => __( 'On a multisite network only a super admin can change the .htaccess file.', '360-orbit-header-security' ), ); } if ( ! self::htaccess_writable() ) { return array( 'ok' => false, 'message' => __( 'The .htaccess file is not writable.', '360-orbit-header-security' ), ); } require_once ABSPATH . 'wp-admin/includes/misc.php'; $backup = get_option( self::OPTION_BACKUP, array() ); self::insert_block( self::htaccess_path(), (array) ( $backup['content'] ?? array() ) ); Wp360_Header_Security_Mu_Sentinel::sync( (array) ( $backup['content'] ?? array() ) ); update_option( self::OPTION_LAST_WRITTEN, time(), false ); $test = self::self_test(); update_option( self::OPTION_LAST_TEST, $test, false ); return $test; } private static function htaccess_writable() { $path = self::htaccess_path(); return is_file( $path ) ? wp_is_writable( $path ) : wp_is_writable( dirname( $path ) ); } /** Loopback-Request auf die Startseite -- entscheidet ueber automatischen Rollback. */ public static function self_test() { $response = wp_remote_get( home_url( '/' ), array( 'timeout' => 10, 'sslverify' => apply_filters( 'https_local_ssl_verify', false ), // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress-Core-Filter. 'headers' => array( 'Cache-Control' => 'no-cache' ), ) ); if ( is_wp_error( $response ) ) { return array( 'ok' => false, /* translators: %s: underlying error message */ 'message' => sprintf( __( 'The homepage could not be reached: %s', '360-orbit-header-security' ), $response->get_error_message() ), 'time' => time(), ); } $code = wp_remote_retrieve_response_code( $response ); if ( $code >= 500 ) { return array( 'ok' => false, /* translators: %d: HTTP status code */ 'message' => sprintf( __( 'The homepage responded with HTTP %d after the change.', '360-orbit-header-security' ), $code ), 'time' => time(), ); } return array( 'ok' => true, /* translators: %d: HTTP status code */ 'message' => sprintf( __( 'The homepage responded with HTTP %d.', '360-orbit-header-security' ), $code ), 'time' => time(), ); } /** * insert_with_markers() schreibt die Datei per implode("\n") -- steht * unser Block am Dateiende, fehlt danach der abschliessende Zeilenumbruch. * Ein spaeter angehaengter Block (z. B. der WordPress-eigene) klebt dann an * der END-Markierung ("# END …# BEGIN WordPress") und macht sie unbrauchbar. */ private static function insert_block( $path, array $lines ) { $result = insert_with_markers( $path, self::MARKER, $lines ); if ( $result && is_readable( $path ) ) { $contents = (string) file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- lokale .htaccess, kein Remote-Zugriff. if ( '' !== $contents && "\n" !== substr( $contents, -1 ) ) { file_put_contents( $path, $contents . "\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents } } return $result; } }