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;
}
}