purge_page_caches(); $back = add_query_arg( 'aieo_st_msg', 'saved', $this->get_url() ); if ( '' !== $readme && 'absent' !== $readme ) { $back = add_query_arg( 'aieo_st_readme', $readme, $back ); } if ( '' !== $lock ) { $back = add_query_arg( 'aieo_st_lock', $lock, $back ); } wp_safe_redirect( $back ); exit; } public function handle_probe() { if ( ! current_user_can( AIEO_CAPABILITY ) ) { wp_die( esc_html__( 'Insufficient permissions.', 'ai-eshop-optimizer' ) ); } check_admin_referer( self::NONCE, self::PROBE_NONCE_FIELD ); $host = wp_parse_url( home_url(), PHP_URL_HOST ); $paths = array( 'wp-includes' => '/wp-includes/version.php', 'wp-content' => '/wp-content/index.php', ); $found = array(); foreach ( $paths as $label => $path ) { $response = wp_remote_head( 'http://127.0.0.1' . $path, array( 'headers' => array( 'Host' => $host ), 'timeout' => 3, 'redirection' => 0, 'sslverify' => false, ) ); $found[ $label ] = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response ); } set_transient( self::PROBE_TRANSIENT, $found, 5 * MINUTE_IN_SECONDS ); wp_safe_redirect( add_query_arg( 'aieo_st_msg', 'probed', $this->get_url() ) ); exit; } private static function option_name() { return AIEO_Security_Hardening::OPTION; } private function purge_page_caches() { do_action( 'aieo_purge_page_cache' ); do_action( 'aieo_security_hardening_saved' ); } private function page_cache_present() { if ( defined( 'WP_ROCKET_VERSION' ) || defined( 'WP_ROCKET_ADVANCED_CACHE' ) ) { return true; } if ( defined( 'WP_CACHE' ) && WP_CACHE ) { return true; } return file_exists( WP_CONTENT_DIR . '/advanced-cache.php' ); } private function version_is_hidden() { $tag = apply_filters( 'the_generator', get_the_generator( 'html' ), 'html' ); return ( '' === trim( (string) $tag ) ) && ! has_action( 'wp_head', 'wp_generator' ); } public function render_subtab() { $on = AIEO_Security_Hardening::settings(); $htaccess = AIEO_Security_Hardening::server_reads_htaccess(); rooster_admin_band_open( array( 'cols' => $htaccess ? 1 : 2 ) ); list( $aieo_st_open, $aieo_st_close ) = AIEO_DMM_UI::card( array( 'anchor' => 'security-tightening', 'title' => __( 'Protections to switch on', 'ai-eshop-optimizer' ), 'description' => __( 'Close the doors a shop does not use. The protective headers and the hidden user list are on from the start, because they break nothing a shop relies on; everything else is off until you switch it on. Each setting says exactly what it does and what it cannot do. Signing in, two-factor and the private admin address are on the previous screen.', 'ai-eshop-optimizer' ), 'class' => 'aieo-st-card', 'col' => 1, 'footer' => '', ) ); echo $aieo_st_open; $this->render_notices(); echo '
'; wp_nonce_field( self::NONCE ); $this->render_toggle( 'hide_version', $on['hide_version'], __( 'Hide the WordPress version', 'ai-eshop-optimizer' ), __( 'Removes the generator tag from your pages and feeds, so a scanner looking for shops on a specific version has to work harder. It does not change the version query on stylesheets and scripts, which is there to break caches, not to hide anything.', 'ai-eshop-optimizer' ), $this->version_is_hidden() ? __( 'Currently hidden.', 'ai-eshop-optimizer' ) : __( 'Currently visible in your pages.', 'ai-eshop-optimizer' ) ); $this->render_toggle( 'disable_file_edit', $on['disable_file_edit'], __( 'Turn off the theme and plugin editors', 'ai-eshop-optimizer' ), __( 'Removes the built-in code editors from the admin. Anyone who gets into an administrator account can otherwise edit your live code from the browser. You will still be able to edit files over SFTP or through your host.', 'ai-eshop-optimizer' ), ( defined( 'DISALLOW_FILE_EDIT' ) && DISALLOW_FILE_EDIT ) ? __( 'Already off in wp-config.php, so this setting changes nothing.', 'ai-eshop-optimizer' ) : '' ); $this->render_toggle( 'disable_xmlrpc', $on['disable_xmlrpc'], __( 'Refuse XML-RPC sign-in and pingbacks', 'ai-eshop-optimizer' ), __( 'Blocks the old remote-publishing interface from accepting a password and stops pingback requests being used to bounce traffic at other sites. The file itself still answers unless your web server blocks it, which is the only way to close it completely. Turn this on unless you publish from a phone app or use Jetpack.', 'ai-eshop-optimizer' ), '' ); $this->render_toggle( 'disable_feeds', $on['disable_feeds'], __( 'Stop serving RSS and Atom feeds', 'ai-eshop-optimizer' ), __( 'Answers every feed address with "gone" instead of your content, which is what stops scrapers republishing your catalogue.', 'ai-eshop-optimizer' ), '', '', __( 'Check first that nothing you rely on reads a feed: a newsletter that builds itself from your latest posts, a price or product feed, or an automation that watches for new items. This is the one setting here that can quietly break something you use.', 'ai-eshop-optimizer' ) ); $this->render_headers_card( $on ); $aieo_st_shown = $this->accounts_showing_login(); $this->render_toggle( 'hide_users', $on['hide_users'], __( 'Hide your users from visitors', 'ai-eshop-optimizer' ), __( 'Visitors and customers can no longer list your accounts through the REST API, find a sign-in name by trying /?author=1, /?author=2 and so on (they get a page that is not found), or read it in the users sitemap, in a comment\'s code or in an embed of a post. Staff who write or edit content keep the block editor\'s author picker and every admin screen. Author pages at their own addresses stay, and no account is renamed.', 'ai-eshop-optimizer' ), $aieo_st_shown > 0 ? sprintf( _n( '%s staff account shows its sign-in name as its public name, which posts and reviews print. Choose another in its profile, under "Display name publicly as".', '%s staff accounts show their sign-in name as their public name, which posts and reviews print. Choose another in each profile, under "Display name publicly as".', $aieo_st_shown, 'ai-eshop-optimizer' ), number_format_i18n( $aieo_st_shown ) ) : '' ); $this->render_readme_card( $on ); if ( $htaccess ) { $this->render_folder_lock_toggle( $on ); } echo '
'; echo $aieo_st_close; if ( ! $htaccess ) { $this->render_system_folders_card(); } rooster_admin_band_close(); } private function render_toggle( $key, $checked, $title, $blurb, $state = '', $label = '', $warning = '' ) { $id = 'aieo-st-' . str_replace( '_', '-', $key ); $control = \Rooster\Admin\Ui::toggle( 'aieo_st[' . $key . ']', (bool) $checked, array( 'id' => $id, 'hidden' => false, 'label' => '' !== $label ? $label : __( 'On', 'ai-eshop-optimizer' ) ) ); $row = \Rooster\Admin\Ui::field_row( array( 'id' => $id, 'label' => $title, 'control' => $control, 'help_html' => esc_html( $blurb ) . ( '' !== $state ? '
' . esc_html( $state ) . '' : '' ), ) ); echo '
' . $row . '
' . ( '' !== $warning ? \Rooster\Admin\Ui::notice( esc_html( $warning ), 'warning', array( 'inline' => true ) ) : '' ); } private function accounts_showing_login() { $users = get_users( array( 'role__in' => array( 'administrator', 'editor', 'author', 'contributor', 'shop_manager' ), 'fields' => array( 'user_login', 'user_nicename', 'display_name' ), 'number' => 500, ) ); $n = 0; foreach ( $users as $user ) { $name = strtolower( trim( (string) $user->display_name ) ); if ( '' === $name || strtolower( (string) $user->user_login ) === $name || strtolower( (string) $user->user_nicename ) === $name ) { ++$n; } } return $n; } private function render_headers_card( array $on ) { $boxes = array( 'send_nosniff' => __( 'Do not sniff types', 'ai-eshop-optimizer' ), 'send_frame_guard' => __( 'No framing by others', 'ai-eshop-optimizer' ), 'send_referrer' => __( 'Trim the referrer', 'ai-eshop-optimizer' ), 'send_hsts' => __( 'HTTPS only (HSTS)', 'ai-eshop-optimizer' ), 'send_permissions' => __( 'No USB, serial or HID devices', 'ai-eshop-optimizer' ), 'header_xss' => __( 'Old XSS header', 'ai-eshop-optimizer' ), ); $control = ''; foreach ( $boxes as $key => $label ) { $control .= '
' . \Rooster\Admin\Ui::toggle( 'aieo_st[' . $key . ']', ! empty( $on[ $key ] ), array( 'id' => 'aieo-st-' . str_replace( '_', '-', $key ), 'hidden' => false, 'label' => $label ) ) . '
'; } $help = esc_html__( 'Standard instructions to the browser: do not second-guess the type of a file; do not let another site frame your pages (your own editor, the Customizer and payment windows are not affected, and WordPress\'s embed view of a post stays open to other sites); do not pass the full address of a page on to other sites; come back over HTTPS only; and keep USB, serial and HID devices away from every script on the page. All but the old XSS header are on from the start: switch one off if something you use needs it.', 'ai-eshop-optimizer' ) . '
' . esc_html__( 'HTTPS only is sent only on pages that arrived over HTTPS, for six months at a time, and for this address alone: no subdomains and no preload list.', 'ai-eshop-optimizer' ) . '' . '
' . esc_html__( 'Applies to pages built by WordPress. Images, stylesheets and scripts are sent by the web server without asking WordPress, so they are not covered here.', 'ai-eshop-optimizer' ) . '' . '
' . esc_html__( 'The last one, the old XSS header, no longer does anything: the browser feature it switched on has been removed from Chrome, Edge and Safari, and Firefox never had it. It is offered because security scanners and audit checklists still report its absence, not because it protects anyone.', 'ai-eshop-optimizer' ) . ''; $row = \Rooster\Admin\Ui::field_row( array( 'id' => 'aieo-st-headers', 'label' => __( 'Send protective response headers', 'ai-eshop-optimizer' ), 'group' => true, 'control' => $control, 'help_html' => $help, ) ); echo '
' . $row . '
'; if ( $this->page_cache_present() ) { echo \Rooster\Admin\Ui::notice( esc_html__( 'A page cache is active on this site. Cached pages are sent to visitors without running PHP, so these headers do not reach them. Saving this page clears the caches it can reach, but the reliable place for these headers is your web server configuration. Checking while signed in will always show them, because a page cache is bypassed for signed-in users.', 'ai-eshop-optimizer' ), 'warning', array( 'inline' => true ) ); } } private function render_readme_card( array $on ) { $present = file_exists( ABSPATH . 'readme.html' ); $this->render_toggle( 'keep_readme_deleted', ! empty( $on['keep_readme_deleted'] ), __( 'Remove the default readme.html', 'ai-eshop-optimizer' ), __( 'WordPress ships a readme file in your site root that names the version. WordPress puts it back every time it updates itself, so removing it once is not enough. Modest value: the licence file next to it says much the same thing.', 'ai-eshop-optimizer' ), $present ? __( 'The file is present.', 'ai-eshop-optimizer' ) : __( 'The file is not there.', 'ai-eshop-optimizer' ), __( 'Keep it removed', 'ai-eshop-optimizer' ) ); } private function render_folder_lock_toggle( array $on ) { $state = AIEO_Security_Hardening::folder_lock_state(); $done = array_filter( $state ); if ( empty( $state ) ) { $note = ''; } elseif ( count( $done ) === count( $state ) ) { $note = __( 'In place in both folders.', 'ai-eshop-optimizer' ); } elseif ( empty( $done ) ) { $note = __( 'Not in place.', 'ai-eshop-optimizer' ); } else { $note = __( 'In place in one of the two folders only. Save again, or check that both are writable.', 'ai-eshop-optimizer' ); } $this->render_toggle( 'lock_system_folders', $on['lock_system_folders'], __( 'Stop PHP running in your uploads and system folders', 'ai-eshop-optimizer' ), __( 'If someone manages to write a file into your uploads folder, the difference between an incident and a break-in is whether the web server agrees to run it. This adds a rule to your uploads and wp-includes folders refusing to run PHP there. Your plugins folder is deliberately left alone: plugins legitimately answer at their own addresses, and blocking those breaks payments and forms.', 'ai-eshop-optimizer' ), $note ); } private function render_system_folders_card() { $probe = get_transient( self::PROBE_TRANSIENT ); list( $aieo_st_open, $aieo_st_close ) = AIEO_DMM_UI::card( array( 'anchor' => 'security-tightening-stop-php-running-in-your-content-folders', 'title' => __( 'Stop PHP running in your content folders', 'ai-eshop-optimizer' ), 'description' => __( 'If someone manages to write a file into your uploads or plugins folder, the difference between an incident and a break-in is whether the web server agrees to run it. This cannot be set from WordPress: it is a rule in your web server, and a plugin that claims otherwise is writing a file your server may never read. Below is what your server answers today, and the rule to give whoever administers it.', 'ai-eshop-optimizer' ), 'class' => 'aieo-st-card', 'col' => 2, 'footer' => '', ) ); echo $aieo_st_open; if ( is_array( $probe ) ) { echo ''; $aieo_st_direct = \Rooster\Admin\Guidance::text( __( 'Answered by this server directly, not through your CDN.', 'ai-eshop-optimizer' ) ); echo $aieo_st_direct; } $aieo_st_give = \Rooster\Admin\Ui::notice( esc_html__( 'Give this to whoever administers the server. Before applying it, have them check the access log for any PHP file under those two folders that is being requested directly and still working, and allow those specifically. Some payment and form plugins are called at their own file.', 'ai-eshop-optimizer' ), 'warning', array( 'inline' => true ) ); echo $aieo_st_give; $aieo_st_snippet = \Rooster\Admin\Ui::textarea( '', "location ~* ^/(?:wp-content|wp-includes)/.*\\.php$ {\n deny all;\n}\n# Check the access log for legitimate direct .php hits under those folders first,\n# and add an allow block for each before applying this.", array( 'id' => 'aieo-st-snippet', 'code' => true, 'rows' => 6, 'readonly' => true, 'aria-label' => __( 'Stop PHP running in your content folders', 'ai-eshop-optimizer' ) ) ); echo $aieo_st_snippet; echo ''; echo '
'; wp_nonce_field( self::NONCE, self::PROBE_NONCE_FIELD ); echo '
'; echo $aieo_st_close; } private function render_notices() { $msg = isset( $_GET['aieo_st_msg'] ) ? sanitize_key( wp_unslash( $_GET['aieo_st_msg'] ) ) : ''; if ( 'saved' === $msg ) { echo \Rooster\Admin\Ui::notice( esc_html__( 'Settings saved.', 'ai-eshop-optimizer' ), 'success', array( 'inline' => true ) ); } $readme = isset( $_GET['aieo_st_readme'] ) ? sanitize_key( wp_unslash( $_GET['aieo_st_readme'] ) ) : ''; $map = array( 'deleted' => array( 'success', __( 'readme.html removed.', 'ai-eshop-optimizer' ) ), 'absent' => array( 'info', __( 'readme.html was not there.', 'ai-eshop-optimizer' ) ), 'failed' => array( 'error', __( 'readme.html could not be removed. Delete it over SFTP or ask your host.', 'ai-eshop-optimizer' ) ), 'unwritable' => array( 'error', __( 'The site root is not writable, so readme.html could not be removed. Delete it over SFTP or ask your host.', 'ai-eshop-optimizer' ) ), ); $lock = isset( $_GET['aieo_st_lock'] ) ? sanitize_key( wp_unslash( $_GET['aieo_st_lock'] ) ) : ''; if ( 'unwritable' === $lock ) { echo \Rooster\Admin\Ui::notice( esc_html__( 'The folder rules could not be written, because one of the folders is not writable. Ask your host to allow it, or add the rules by hand.', 'ai-eshop-optimizer' ), 'destructive', array( 'inline' => true ) ); } elseif ( 'failed' === $lock ) { echo \Rooster\Admin\Ui::notice( esc_html__( 'The folder rules could not be written. Nothing was changed.', 'ai-eshop-optimizer' ), 'destructive', array( 'inline' => true ) ); } if ( isset( $map[ $readme ] ) ) { $aieo_st_tone = 'error' === $map[ $readme ][0] ? 'destructive' : $map[ $readme ][0]; $aieo_st_notice = \Rooster\Admin\Ui::notice( esc_html( $map[ $readme ][1] ), $aieo_st_tone, array( 'inline' => true ) ); echo $aieo_st_notice; } } }