Plugin Directory

Changeset 3474999


Ignore:
Timestamp:
03/04/2026 10:30:47 PM (3 weeks ago)
Author:
Marc4
Message:

v0.9

Location:
security-hardener
Files:
4 added
4 edited

Legend:

Unmodified
Added
Removed
  • security-hardener/assets/blueprints/blueprint.json

    r3470729 r3474999  
    1616      "pluginZipFile": {
    1717        "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"
    1919      },
    2020      "options": {
  • security-hardener/trunk/readme.txt

    r3470719 r3474999  
    22Contributors: marc4
    33Tags: security, hardening, headers, brute force, login protection
    4 Requires at least: 6.0
     4Requires at least: 6.9
    55Tested up to: 6.9
    6 Requires PHP: 8.0
    7 Stable tag: 0.8
     6Requires PHP: 8.3
     7Stable tag: 0.9
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    122122= What happens to my data when I uninstall? =
    123123
    124 When you **uninstall** (not just deactivate) the plugin:
     124When 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:
    125125* All plugin settings are deleted
    126126* All security logs are deleted
     
    128128* Your WordPress installation is returned to its default state
    129129
    130 **Note:** Deactivating the plugin preserves all settings.
     130**Note:** Deactivating the plugin always preserves all settings.
    131131
    132132= Does this block the WordPress REST API? =
     
    160160
    161161== 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`
    162170
    163171= 0.8 - 2026-02-26 =
  • security-hardener/trunk/security-hardener.php

    r3470719 r3474999  
    44Plugin URI: https://wordpress.org/plugins/security-hardener/
    55Description: Basic hardening: secure headers, disable XML-RPC/pingbacks, hide version, block user enumeration, generic login errors, and IP-based rate limiting.
    6 Version: 0.8
    7 Requires at least: 6.0
     6Version: 0.9
     7Requires at least: 6.9
    88Tested up to: 6.9
    9 Requires PHP: 8.0
     9Requires PHP: 8.3
    1010Author: Marc Armengou
    1111Author URI: https://www.marcarmengou.com/
     
    2020
    2121// Plugin constants
    22 define( 'WPSH_VERSION', '0.8' );
     22define( 'WPSH_VERSION', '0.9' );
    2323define( 'WPSH_FILE', __FILE__ );
    2424define( 'WPSH_DIR', plugin_dir_path( __FILE__ ) );
     
    4343         * @var WPHN_Hardener|null
    4444         */
    45         private static $instance = null;
     45        private static ?self $instance = null;
    4646
    4747        /**
    4848         * Plugin options
    4949         *
    50          * @var array
    51          */
    52         private $options = array();
     50         * @var array<string, mixed>
     51         */
     52        private array $options = [];
    5353
    5454        /**
     
    5757         * @return WPHN_Hardener
    5858         */
    59         public static function get_instance() {
     59        public static function get_instance(): static {
    6060            if ( null === self::$instance ) {
    6161                self::$instance = new self();
     
    8585         * Initialize plugin
    8686         */
    87         public function init() {
     87        public function init(): void {
    8888            // Security headers
    8989            add_action( 'send_headers', array( $this, 'send_security_headers' ) );
     
    149149         * Plugin activation
    150150         */
    151         public function activate() {
     151        public function activate(): void {
    152152            // Set default options only if they don't exist
    153153            if ( false === get_option( self::OPTION_NAME ) ) {
     
    163163         * Plugin deactivation
    164164         */
    165         public function deactivate() {
     165        public function deactivate(): void {
    166166            // Log deactivation
    167167            $this->log_security_event( 'plugin_deactivated', 'Security Hardener plugin deactivated' );
     
    173173         * @return array
    174174         */
    175         private function get_default_options() {
    176             return array(
     175        private function get_default_options(): array {
     176            return [
    177177                // File editing
    178                 'disable_file_edit'    => 1,
    179                 'disable_file_mods'    => 0, // Disabled by default as it breaks updates
     178                'disable_file_edit'        => 1,
     179                'disable_file_mods'        => 0, // Disabled by default as it breaks updates
    180180
    181181                // XML-RPC
    182                 'disable_xmlrpc'       => 1,
     182                'disable_xmlrpc'           => 1,
    183183
    184184                // Version hiding
    185                 'hide_wp_version'      => 1,
     185                'hide_wp_version'          => 1,
    186186
    187187                // User enumeration
    188                 'block_user_enum'      => 1,
     188                'block_user_enum'          => 1,
    189189
    190190                // 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,
    195195
    196196                // Pingbacks
    197                 'disable_pingbacks'    => 1,
     197                'disable_pingbacks'        => 1,
    198198
    199199                // Clean wp_head
    200                 'clean_head'           => 1,
     200                'clean_head'               => 1,
    201201
    202202                // 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,
    208208
    209209                // HTTPS
    210                 'enable_hsts'          => 0, // Off by default - requires HTTPS
    211                 '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,
    214214
    215215                // Advanced
    216                 'log_security_events'  => 1,
    217             );
     216                'log_security_events'      => 1,
     217                'delete_data_on_uninstall' => 0,
     218            ];
    218219        }
    219220
     
    223224         * @return array
    224225         */
    225         private function get_options() {
     226        private function get_options(): array {
    226227            $options  = get_option( self::OPTION_NAME, array() );
    227228            $defaults = $this->get_default_options();
     
    236237         * @return mixed
    237238         */
    238         private function get_option( $key, $default = null ) {
     239        private function get_option( $key, $default = null ): mixed {
    239240            if ( isset( $this->options[ $key ] ) ) {
    240241                return $this->options[ $key ];
     
    246247         * Define security constants based on plugin settings
    247248         */
    248         private function define_security_constants() {
     249        private function define_security_constants(): void {
    249250            // Disable file editing in WordPress admin
    250251            if ( $this->get_option( 'disable_file_edit', true ) && ! defined( 'DISALLOW_FILE_EDIT' ) ) {
     
    261262         * Send security headers
    262263         */
    263         public function send_security_headers() {
     264        public function send_security_headers(): void {
    264265            if ( ! $this->get_option( 'enable_headers', true ) ) {
    265266                return;
     
    314315         * @return array
    315316         */
    316         public function remove_xmlrpc_pingback( $methods ) {
     317        public function remove_xmlrpc_pingback( $methods ): array {
    317318            unset( $methods['pingback.ping'] );
    318319            unset( $methods['pingback.extensions.getPingbacks'] );
     
    323324         * Prevent user enumeration via ?author=N
    324325         */
    325         public function prevent_user_enumeration() {
     326        public function prevent_user_enumeration(): void {
    326327            // Don't interfere in admin
    327328            if ( is_admin() ) {
     
    350351         * @return string|false
    351352         */
    352         public function prevent_author_redirect( $redirect_url, $requested_url ) {
     353        public function prevent_author_redirect( $redirect_url, $requested_url ): string|false {
    353354            // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only check
    354355            $author = isset( $_GET['author'] ) ? sanitize_text_field( wp_unslash( $_GET['author'] ) ) : null;
     
    365366         * @return array
    366367         */
    367         public function secure_user_endpoints( $endpoints ) {
     368        public function secure_user_endpoints( $endpoints ): array {
    368369            if ( ! is_array( $endpoints ) ) {
    369370                return $endpoints;
     
    398399         * @return WP_Sitemaps_Provider|false
    399400         */
    400         public function remove_users_sitemap( $provider, $name ) {
     401        public function remove_users_sitemap( $provider, $name ): \WP_Sitemaps_Provider|false {
    401402            return ( 'users' === $name ) ? false : $provider;
    402403        }
     
    408409         * @return string
    409410         */
    410         public function generic_login_errors( $error ) {
     411        public function generic_login_errors( $error ): string {
    411412            // Don't change the error if it's empty
    412413            if ( empty( $error ) ) {
     
    421422         * Remove login hints from login page
    422423         */
    423         public function remove_login_hints() {
     424        public function remove_login_hints(): void {
    424425            // Remove "lost password" text that reveals if username exists
    425426            add_filter(
     
    442443         * @return WP_User|WP_Error
    443444         */
    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 {
    445446            // Skip if credentials are empty
    446447            if ( empty( $username ) || empty( $password ) ) {
     
    475476         * @param string $username Username used in failed attempt.
    476477         */
    477         public function log_failed_login( $username ) {
     478        public function log_failed_login( $username ): void {
    478479            $ip           = $this->get_client_ip();
    479480            $attempts_key = 'wpsh_login_attempts_' . md5( $ip );
     
    522523         * @param WP_User $user User object.
    523524         */
    524         public function clear_login_attempts( $user_login, $user ) {
     525        public function clear_login_attempts( $user_login, $user ): void {
    525526            $ip           = $this->get_client_ip();
    526527            $attempts_key = 'wpsh_login_attempts_' . md5( $ip );
     
    536537         * @return string
    537538         */
    538         private function get_client_ip() {
     539        private function get_client_ip(): string {
    539540            $ip = '0.0.0.0';
    540541
     
    580581         * @param array $links Links to ping.
    581582         */
    582         public function disable_self_pingbacks( &$links ) {
     583        public function disable_self_pingbacks( &$links ): void {
    583584            $home = get_option( 'home' );
    584585            foreach ( $links as $l => $link ) {
     
    595596         * @return array
    596597         */
    597         public function remove_x_pingback( $headers ) {
     598        public function remove_x_pingback( $headers ): array {
    598599            unset( $headers['X-Pingback'] );
    599600            return $headers;
     
    603604         * Clean up wp_head
    604605         */
    605         private function cleanup_wp_head() {
     606        private function cleanup_wp_head(): void {
    606607            // Remove RSD link
    607608            remove_action( 'wp_head', 'rsd_link' );
     
    632633         * @param string $message Event message.
    633634         */
    634         private function log_security_event( $event_type, $message ) {
     635        private function log_security_event( $event_type, $message ): void {
    635636            if ( ! $this->get_option( 'log_security_events', true ) ) {
    636637                return;
     
    664665         * Add admin menu
    665666         */
    666         public function add_admin_menu() {
     667        public function add_admin_menu(): void {
    667668            add_options_page(
    668669                __( 'Security Hardener', 'security-hardener' ),
     
    677678         * Register settings
    678679         */
    679         public function register_settings() {
     680        public function register_settings(): void {
    680681            register_setting(
    681682                'wpsh_settings',
     
    810811            $this->add_checkbox_field( 'clean_head', __( 'Clean wp_head', 'security-hardener' ), 'wpsh_other', __( 'Remove unnecessary items from &lt;head&gt; section.', 'security-hardener' ) );
    811812            $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' ) );
    812814        }
    813815
     
    820822         * @param string $description Optional description.
    821823         */
    822         private function add_checkbox_field( $field_id, $label, $section, $description = '' ) {
     824        private function add_checkbox_field( $field_id, $label, $section, $description = '' ): void {
    823825            add_settings_field(
    824826                $field_id,
     
    844846         * }
    845847         */
    846         public function render_checkbox_field( $args ) {
     848        public function render_checkbox_field( $args ): void {
    847849            $field_id = $args['field_id'];
    848850            $value    = $this->get_option( $field_id, 0 );
     
    863865         * @param array $args Field arguments.
    864866         */
    865         public function render_number_field( $args ) {
     867        public function render_number_field( $args ): void {
    866868            $field_id = $args['field_id'];
    867869            $value    = $this->get_option( $field_id, $args['default'] );
     
    885887         * @return array
    886888         */
    887         public function sanitize_options( $input ) {
     889        public function sanitize_options( $input ): array {
    888890            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 = [
    896898                'disable_file_edit',
    897899                'disable_file_mods',
     
    912914                'clean_head',
    913915                'log_security_events',
    914             );
     916                'delete_data_on_uninstall',
     917            ];
    915918
    916919            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                };
    918924            }
    919925
     
    937943         * Render settings page
    938944         */
    939         public function render_settings_page() {
     945        public function render_settings_page(): void {
    940946            if ( ! current_user_can( 'manage_options' ) ) {
    941947                wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'security-hardener' ) );
     
    9971003         * Render security logs section
    9981004         */
    999         private function render_security_logs() {
     1005        private function render_security_logs(): void {
    10001006            if ( ! $this->get_option( 'log_security_events', true ) ) {
    10011007                return;
     
    10511057         * Show admin notices
    10521058         */
    1053         public function show_admin_notices() {
     1059        public function show_admin_notices(): void {
    10541060            // Clear logs action - verify nonce to prevent CSRF
    10551061            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
     
    10721078         * Check file permissions and show notice
    10731079         */
    1074         private function check_file_permissions_notice() {
     1080        private function check_file_permissions_notice(): void {
    10751081            // Only show on plugin settings page
    10761082            $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
     
    11601166         * @return array
    11611167         */
    1162         public function add_settings_link( $links ) {
     1168        public function add_settings_link( $links ): array {
    11631169            $settings_link = sprintf(
    11641170                '<a href="%s">%s</a>',
  • security-hardener/trunk/uninstall.php

    r3466579 r3474999  
    99 */
    1010
    11 // If uninstall not called from WordPress, exit
     11// If uninstall not called from WordPress, exit.
    1212if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
    1313    exit;
    1414}
    1515
    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
     21if ( ! $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.
    1729delete_option( 'wpsh_options' );
    1830
    19 // Delete security logs
     31// Delete security logs.
    2032delete_option( 'wpsh_security_logs' );
    2133
    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
    2346global $wpdb;
    2447
    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 );
     48foreach ( $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    );
    3656
    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.
    3865wp_cache_flush();
Note: See TracChangeset for help on using the changeset viewer.