Changeset 3474999
- Timestamp:
- 03/04/2026 10:30:47 PM (3 weeks ago)
- Location:
- security-hardener
- Files:
-
- 4 added
- 4 edited
-
assets/blueprints/blueprint.json (modified) (1 diff)
-
tags/0.9 (added)
-
tags/0.9/readme.txt (added)
-
tags/0.9/security-hardener.php (added)
-
tags/0.9/uninstall.php (added)
-
trunk/readme.txt (modified) (4 diffs)
-
trunk/security-hardener.php (modified) (40 diffs)
-
trunk/uninstall.php (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
security-hardener/assets/blueprints/blueprint.json
r3470729 r3474999 16 16 "pluginZipFile": { 17 17 "resource": "url", 18 "url": "https://downloads.wordpress.org/plugin/security-hardener.0. 8.zip"18 "url": "https://downloads.wordpress.org/plugin/security-hardener.0.9.zip" 19 19 }, 20 20 "options": { -
security-hardener/trunk/readme.txt
r3470719 r3474999 2 2 Contributors: marc4 3 3 Tags: security, hardening, headers, brute force, login protection 4 Requires at least: 6. 04 Requires at least: 6.9 5 5 Tested up to: 6.9 6 Requires PHP: 8. 07 Stable tag: 0. 86 Requires PHP: 8.3 7 Stable tag: 0.9 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 122 122 = What happens to my data when I uninstall? = 123 123 124 When you **uninstall** (not just deactivate) the plugin :124 When you **uninstall** (not just deactivate) the plugin, data is preserved by default. If you have enabled the **"Delete all data on uninstall"** option under Settings > Security Hardener > Other Settings, then on uninstall: 125 125 * All plugin settings are deleted 126 126 * All security logs are deleted … … 128 128 * Your WordPress installation is returned to its default state 129 129 130 **Note:** Deactivating the plugin preserves all settings.130 **Note:** Deactivating the plugin always preserves all settings. 131 131 132 132 = Does this block the WordPress REST API? = … … 160 160 161 161 == Changelog == 162 163 = 0.9 - 2026-03-04 = 164 * Updated: Minimum WordPress requirement raised to 6.9 (not backward compatible) 165 * Updated: Minimum PHP requirement raised to 8.3 (not backward compatible) 166 * Improved: Applied PHP 8.3 modern syntax throughout — typed class properties, explicit return types on all methods, short array syntax, and `match` expression in `sanitize_options()` 167 * Added: `delete_data_on_uninstall` option (default: disabled) — users must explicitly opt in to data deletion on uninstall; data is preserved by default 168 * Fixed: `uninstall.php` no longer uses direct SQL queries; deletion is now conditional on the opt-in option and uses WordPress APIs exclusively 169 * Fixed: `WPSH_VERSION` constant kept in sync with plugin header at `0.9` 162 170 163 171 = 0.8 - 2026-02-26 = -
security-hardener/trunk/security-hardener.php
r3470719 r3474999 4 4 Plugin URI: https://wordpress.org/plugins/security-hardener/ 5 5 Description: Basic hardening: secure headers, disable XML-RPC/pingbacks, hide version, block user enumeration, generic login errors, and IP-based rate limiting. 6 Version: 0. 87 Requires at least: 6. 06 Version: 0.9 7 Requires at least: 6.9 8 8 Tested up to: 6.9 9 Requires PHP: 8. 09 Requires PHP: 8.3 10 10 Author: Marc Armengou 11 11 Author URI: https://www.marcarmengou.com/ … … 20 20 21 21 // Plugin constants 22 define( 'WPSH_VERSION', '0. 8' );22 define( 'WPSH_VERSION', '0.9' ); 23 23 define( 'WPSH_FILE', __FILE__ ); 24 24 define( 'WPSH_DIR', plugin_dir_path( __FILE__ ) ); … … 43 43 * @var WPHN_Hardener|null 44 44 */ 45 private static $instance = null;45 private static ?self $instance = null; 46 46 47 47 /** 48 48 * Plugin options 49 49 * 50 * @var array 51 */ 52 private $options = array();50 * @var array<string, mixed> 51 */ 52 private array $options = []; 53 53 54 54 /** … … 57 57 * @return WPHN_Hardener 58 58 */ 59 public static function get_instance() {59 public static function get_instance(): static { 60 60 if ( null === self::$instance ) { 61 61 self::$instance = new self(); … … 85 85 * Initialize plugin 86 86 */ 87 public function init() {87 public function init(): void { 88 88 // Security headers 89 89 add_action( 'send_headers', array( $this, 'send_security_headers' ) ); … … 149 149 * Plugin activation 150 150 */ 151 public function activate() {151 public function activate(): void { 152 152 // Set default options only if they don't exist 153 153 if ( false === get_option( self::OPTION_NAME ) ) { … … 163 163 * Plugin deactivation 164 164 */ 165 public function deactivate() {165 public function deactivate(): void { 166 166 // Log deactivation 167 167 $this->log_security_event( 'plugin_deactivated', 'Security Hardener plugin deactivated' ); … … 173 173 * @return array 174 174 */ 175 private function get_default_options() {176 return array(175 private function get_default_options(): array { 176 return [ 177 177 // File editing 178 'disable_file_edit' => 1,179 'disable_file_mods' => 0, // Disabled by default as it breaks updates178 'disable_file_edit' => 1, 179 'disable_file_mods' => 0, // Disabled by default as it breaks updates 180 180 181 181 // XML-RPC 182 'disable_xmlrpc' => 1,182 'disable_xmlrpc' => 1, 183 183 184 184 // Version hiding 185 'hide_wp_version' => 1,185 'hide_wp_version' => 1, 186 186 187 187 // User enumeration 188 'block_user_enum' => 1,188 'block_user_enum' => 1, 189 189 190 190 // Login security 191 'secure_login' => 1,192 'rate_limit_login' => 1,193 'rate_limit_attempts' => 5,194 'rate_limit_minutes' => 15,191 'secure_login' => 1, 192 'rate_limit_login' => 1, 193 'rate_limit_attempts' => 5, 194 'rate_limit_minutes' => 15, 195 195 196 196 // Pingbacks 197 'disable_pingbacks' => 1,197 'disable_pingbacks' => 1, 198 198 199 199 // Clean wp_head 200 'clean_head' => 1,200 'clean_head' => 1, 201 201 202 202 // Security headers 203 'enable_headers' => 1,204 'header_x_frame' => 1,205 'header_x_content' => 1,206 'header_referrer' => 1,207 'header_permissions' => 1,203 'enable_headers' => 1, 204 'header_x_frame' => 1, 205 'header_x_content' => 1, 206 'header_referrer' => 1, 207 'header_permissions' => 1, 208 208 209 209 // HTTPS 210 'enable_hsts' => 0, // Off by default - requires HTTPS211 'hsts_max_age' => 31536000,212 'hsts_subdomains' => 1,213 'hsts_preload' => 0,210 'enable_hsts' => 0, // Off by default - requires HTTPS 211 'hsts_max_age' => 31536000, 212 'hsts_subdomains' => 1, 213 'hsts_preload' => 0, 214 214 215 215 // Advanced 216 'log_security_events' => 1, 217 ); 216 'log_security_events' => 1, 217 'delete_data_on_uninstall' => 0, 218 ]; 218 219 } 219 220 … … 223 224 * @return array 224 225 */ 225 private function get_options() {226 private function get_options(): array { 226 227 $options = get_option( self::OPTION_NAME, array() ); 227 228 $defaults = $this->get_default_options(); … … 236 237 * @return mixed 237 238 */ 238 private function get_option( $key, $default = null ) {239 private function get_option( $key, $default = null ): mixed { 239 240 if ( isset( $this->options[ $key ] ) ) { 240 241 return $this->options[ $key ]; … … 246 247 * Define security constants based on plugin settings 247 248 */ 248 private function define_security_constants() {249 private function define_security_constants(): void { 249 250 // Disable file editing in WordPress admin 250 251 if ( $this->get_option( 'disable_file_edit', true ) && ! defined( 'DISALLOW_FILE_EDIT' ) ) { … … 261 262 * Send security headers 262 263 */ 263 public function send_security_headers() {264 public function send_security_headers(): void { 264 265 if ( ! $this->get_option( 'enable_headers', true ) ) { 265 266 return; … … 314 315 * @return array 315 316 */ 316 public function remove_xmlrpc_pingback( $methods ) {317 public function remove_xmlrpc_pingback( $methods ): array { 317 318 unset( $methods['pingback.ping'] ); 318 319 unset( $methods['pingback.extensions.getPingbacks'] ); … … 323 324 * Prevent user enumeration via ?author=N 324 325 */ 325 public function prevent_user_enumeration() {326 public function prevent_user_enumeration(): void { 326 327 // Don't interfere in admin 327 328 if ( is_admin() ) { … … 350 351 * @return string|false 351 352 */ 352 public function prevent_author_redirect( $redirect_url, $requested_url ) {353 public function prevent_author_redirect( $redirect_url, $requested_url ): string|false { 353 354 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only check 354 355 $author = isset( $_GET['author'] ) ? sanitize_text_field( wp_unslash( $_GET['author'] ) ) : null; … … 365 366 * @return array 366 367 */ 367 public function secure_user_endpoints( $endpoints ) {368 public function secure_user_endpoints( $endpoints ): array { 368 369 if ( ! is_array( $endpoints ) ) { 369 370 return $endpoints; … … 398 399 * @return WP_Sitemaps_Provider|false 399 400 */ 400 public function remove_users_sitemap( $provider, $name ) {401 public function remove_users_sitemap( $provider, $name ): \WP_Sitemaps_Provider|false { 401 402 return ( 'users' === $name ) ? false : $provider; 402 403 } … … 408 409 * @return string 409 410 */ 410 public function generic_login_errors( $error ) {411 public function generic_login_errors( $error ): string { 411 412 // Don't change the error if it's empty 412 413 if ( empty( $error ) ) { … … 421 422 * Remove login hints from login page 422 423 */ 423 public function remove_login_hints() {424 public function remove_login_hints(): void { 424 425 // Remove "lost password" text that reveals if username exists 425 426 add_filter( … … 442 443 * @return WP_User|WP_Error 443 444 */ 444 public function check_login_rate_limit( $user, $username, $password ) {445 public function check_login_rate_limit( $user, $username, $password ): \WP_User|\WP_Error|null { 445 446 // Skip if credentials are empty 446 447 if ( empty( $username ) || empty( $password ) ) { … … 475 476 * @param string $username Username used in failed attempt. 476 477 */ 477 public function log_failed_login( $username ) {478 public function log_failed_login( $username ): void { 478 479 $ip = $this->get_client_ip(); 479 480 $attempts_key = 'wpsh_login_attempts_' . md5( $ip ); … … 522 523 * @param WP_User $user User object. 523 524 */ 524 public function clear_login_attempts( $user_login, $user ) {525 public function clear_login_attempts( $user_login, $user ): void { 525 526 $ip = $this->get_client_ip(); 526 527 $attempts_key = 'wpsh_login_attempts_' . md5( $ip ); … … 536 537 * @return string 537 538 */ 538 private function get_client_ip() {539 private function get_client_ip(): string { 539 540 $ip = '0.0.0.0'; 540 541 … … 580 581 * @param array $links Links to ping. 581 582 */ 582 public function disable_self_pingbacks( &$links ) {583 public function disable_self_pingbacks( &$links ): void { 583 584 $home = get_option( 'home' ); 584 585 foreach ( $links as $l => $link ) { … … 595 596 * @return array 596 597 */ 597 public function remove_x_pingback( $headers ) {598 public function remove_x_pingback( $headers ): array { 598 599 unset( $headers['X-Pingback'] ); 599 600 return $headers; … … 603 604 * Clean up wp_head 604 605 */ 605 private function cleanup_wp_head() {606 private function cleanup_wp_head(): void { 606 607 // Remove RSD link 607 608 remove_action( 'wp_head', 'rsd_link' ); … … 632 633 * @param string $message Event message. 633 634 */ 634 private function log_security_event( $event_type, $message ) {635 private function log_security_event( $event_type, $message ): void { 635 636 if ( ! $this->get_option( 'log_security_events', true ) ) { 636 637 return; … … 664 665 * Add admin menu 665 666 */ 666 public function add_admin_menu() {667 public function add_admin_menu(): void { 667 668 add_options_page( 668 669 __( 'Security Hardener', 'security-hardener' ), … … 677 678 * Register settings 678 679 */ 679 public function register_settings() {680 public function register_settings(): void { 680 681 register_setting( 681 682 'wpsh_settings', … … 810 811 $this->add_checkbox_field( 'clean_head', __( 'Clean wp_head', 'security-hardener' ), 'wpsh_other', __( 'Remove unnecessary items from <head> section.', 'security-hardener' ) ); 811 812 $this->add_checkbox_field( 'log_security_events', __( 'Log security events', 'security-hardener' ), 'wpsh_other', __( 'Keep a log of security events (last 100 entries).', 'security-hardener' ) ); 813 $this->add_checkbox_field( 'delete_data_on_uninstall', __( 'Delete all data on uninstall', 'security-hardener' ), 'wpsh_other', '<strong>' . esc_html__( 'Warning:', 'security-hardener' ) . '</strong> ' . esc_html__( 'When enabled, all plugin settings and security logs will be permanently deleted when the plugin is uninstalled. Disabled by default to preserve data.', 'security-hardener' ) ); 812 814 } 813 815 … … 820 822 * @param string $description Optional description. 821 823 */ 822 private function add_checkbox_field( $field_id, $label, $section, $description = '' ) {824 private function add_checkbox_field( $field_id, $label, $section, $description = '' ): void { 823 825 add_settings_field( 824 826 $field_id, … … 844 846 * } 845 847 */ 846 public function render_checkbox_field( $args ) {848 public function render_checkbox_field( $args ): void { 847 849 $field_id = $args['field_id']; 848 850 $value = $this->get_option( $field_id, 0 ); … … 863 865 * @param array $args Field arguments. 864 866 */ 865 public function render_number_field( $args ) {867 public function render_number_field( $args ): void { 866 868 $field_id = $args['field_id']; 867 869 $value = $this->get_option( $field_id, $args['default'] ); … … 885 887 * @return array 886 888 */ 887 public function sanitize_options( $input ) {889 public function sanitize_options( $input ): array { 888 890 if ( ! is_array( $input ) ) { 889 $input = array();890 } 891 892 $sanitized = array();893 894 // Boolean fields 895 $boolean_fields = array(891 $input = []; 892 } 893 894 $sanitized = []; 895 896 // Boolean fields — use match to be explicit about the 1/0 cast. 897 $boolean_fields = [ 896 898 'disable_file_edit', 897 899 'disable_file_mods', … … 912 914 'clean_head', 913 915 'log_security_events', 914 ); 916 'delete_data_on_uninstall', 917 ]; 915 918 916 919 foreach ( $boolean_fields as $field ) { 917 $sanitized[ $field ] = ! empty( $input[ $field ] ) ? 1 : 0; 920 $sanitized[ $field ] = match ( true ) { 921 ! empty( $input[ $field ] ) => 1, 922 default => 0, 923 }; 918 924 } 919 925 … … 937 943 * Render settings page 938 944 */ 939 public function render_settings_page() {945 public function render_settings_page(): void { 940 946 if ( ! current_user_can( 'manage_options' ) ) { 941 947 wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'security-hardener' ) ); … … 997 1003 * Render security logs section 998 1004 */ 999 private function render_security_logs() {1005 private function render_security_logs(): void { 1000 1006 if ( ! $this->get_option( 'log_security_events', true ) ) { 1001 1007 return; … … 1051 1057 * Show admin notices 1052 1058 */ 1053 public function show_admin_notices() {1059 public function show_admin_notices(): void { 1054 1060 // Clear logs action - verify nonce to prevent CSRF 1055 1061 if ( isset( $_GET['action'] ) && 'clear_logs' === $_GET['action'] && current_user_can( 'manage_options' ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce is verified on the next line … … 1072 1078 * Check file permissions and show notice 1073 1079 */ 1074 private function check_file_permissions_notice() {1080 private function check_file_permissions_notice(): void { 1075 1081 // Only show on plugin settings page 1076 1082 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; … … 1160 1166 * @return array 1161 1167 */ 1162 public function add_settings_link( $links ) {1168 public function add_settings_link( $links ): array { 1163 1169 $settings_link = sprintf( 1164 1170 '<a href="%s">%s</a>', -
security-hardener/trunk/uninstall.php
r3466579 r3474999 9 9 */ 10 10 11 // If uninstall not called from WordPress, exit 11 // If uninstall not called from WordPress, exit. 12 12 if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { 13 13 exit; 14 14 } 15 15 16 // Delete plugin options 16 // Only delete data if the user has explicitly opted in. 17 // Retrieve the option before deleting it so we can honour the preference. 18 $wpsh_options = get_option( 'wpsh_options', [] ); 19 $wpsh_delete_data = ! empty( $wpsh_options['delete_data_on_uninstall'] ); 20 21 if ( ! $wpsh_delete_data ) { 22 // Data-preservation is the default — leave everything intact. 23 return; 24 } 25 26 // --- Opt-in path: remove all plugin data --- 27 28 // Delete plugin options. 17 29 delete_option( 'wpsh_options' ); 18 30 19 // Delete security logs 31 // Delete security logs. 20 32 delete_option( 'wpsh_security_logs' ); 21 33 22 // Clean up transients for login rate limiting 34 // Delete login rate-limiting transients. 35 // We cannot enumerate every hashed IP key ahead of time, so we retrieve 36 // their option_names via a single parameterised SELECT (no raw DELETE), 37 // then remove each entry through the standard WordPress Options API. 38 // wp_cache_flush() clears any remaining in-memory copies afterwards. 39 $wpsh_transient_prefixes = [ 40 '_transient_wpsh_login_attempts_', 41 '_transient_timeout_wpsh_login_attempts_', 42 '_transient_wpsh_login_blocked_', 43 '_transient_timeout_wpsh_login_blocked_', 44 ]; 45 23 46 global $wpdb; 24 47 25 // Delete login attempts transients using a direct SQL query. 26 // There is no WordPress API to delete transients by pattern, so a direct query 27 // is the only reliable approach here. Caching is intentionally skipped in an 28 // uninstall context; the object cache is flushed immediately afterwards. 29 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching 30 "DELETE FROM {$wpdb->options} 31 WHERE option_name LIKE '_transient_wpsh_login_attempts_%' 32 OR option_name LIKE '_transient_timeout_wpsh_login_attempts_%' 33 OR option_name LIKE '_transient_wpsh_login_blocked_%' 34 OR option_name LIKE '_transient_timeout_wpsh_login_blocked_%'" 35 ); 48 foreach ( $wpsh_transient_prefixes as $wpsh_prefix ) { 49 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching 50 $wpsh_option_names = $wpdb->get_col( 51 $wpdb->prepare( 52 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", 53 $wpdb->esc_like( $wpsh_prefix ) . '%' 54 ) 55 ); 36 56 37 // Flush the object cache to remove any in-memory copies of the deleted transients. 57 if ( ! empty( $wpsh_option_names ) ) { 58 foreach ( $wpsh_option_names as $wpsh_option_name ) { 59 delete_option( $wpsh_option_name ); 60 } 61 } 62 } 63 64 // Flush the object cache to remove any in-memory copies of the deleted data. 38 65 wp_cache_flush();
Note: See TracChangeset
for help on using the changeset viewer.