Plugin Directory

Changeset 3753699


Ignore:
Timestamp:
10/18/2026 11:12:11 PM (2 hours ago)
Author:
yuracode
Message:

Release YuraCode Security 1.0.6

Location:
yuracode-security
Files:
18 edited
1 copied

Legend:

Unmodified
Added
Removed
  • yuracode-security/tags/1.0.6/readme.txt

    r3714465 r3753699  
    55Tested up to: 7.1
    66Requires PHP: 8.0
    7 Stable tag: 1.0.5
     7Stable tag: 1.0.6
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    3232Think of this as a bouncer at the door of your website. It checks every visitor before your site even loads and kicks out the troublemakers.
    3333* **Blocks malicious visitors:** Recognizes known attack patterns and blocks them immediately.
    34 * **Protects your private files:** Ensures sensitive files (like your site's database settings or backups) can't be downloaded by strangers.
     34* **Protects your private files:** Ensures sensitive files (like your site's database settings, backups, log files, and the WordPress version files) can't be downloaded by strangers.
    3535* **Stops malicious uploads:** If an attacker manages to upload a file, this prevents the file from actually running any harmful code on your server.
    3636
    … …  
    6969* Sends conservative response headers on the front end: `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, and `X-Frame-Options: SAMEORIGIN`. Each is only sent when nothing else has already set it.
    7070* Login protection throttles an IP address after too many failed attempts.
    71 * The firewall uses the 8G ruleset by Perishable Press, written to `.htaccess` and the uploads folder, to block malicious query strings, request URIs, user agents, and referrers. It also protects `wp-config.php` and blocks PHP execution in `/uploads`.
     71* The firewall uses the 8G ruleset by Perishable Press, written to `.htaccess` and the uploads folder, to block malicious query strings, request URIs, user agents, and referrers. It also protects `wp-config.php`, denies public access to backup, dump, and log files (`.bak`, `.old`, `.tmp`, `.swp`, `.save`, `.backup`, `.ini`, `.sql`, `.log`) as well as `readme.html` and `license.txt`, and blocks PHP execution in `/uploads`.
    7272
    7373== Privacy ==
    … …  
    8686
    8787== Changelog ==
     88
     89= 1.0.6 =
     90* Multisite networks: the main site now drives the shared firewall, and direct access to readme.html, license.txt and licencia.txt is blocked.
    8891
    8992= 1.0.5 =
  • yuracode-security/tags/1.0.6/src/Admin/FieldRenderer.php

    r3714465 r3753699  
    1919                ?>
    2020                <label class="ycs-switch">
    21                         <input type="checkbox" id="<?php echo esc_attr($id); ?>" name="<?php echo
    22                                 esc_attr($name); ?>" value="1" <?php checked(!empty($value)); ?> <?php disabled(!empty($args['disabled'])); ?> />
     21                        <input type="checkbox" id="<?php echo esc_attr($id); ?>" name="<?php echo esc_attr(
     22                                $name,
     23                        ); ?>" value="1" <?php checked(!empty($value)); ?> <?php disabled(!empty($args['disabled'])); ?> />
    2324                        <span class="ycs-switch__slider"></span>
    2425                </label>
    … …  
    3738                // field. The visible width comes from WP's .small-text, not `size`.
    3839                ?>
    39                 <input type="number" autocomplete="off" size="1" id="<?php echo esc_attr($id); ?>" name="<?php echo
    40                         esc_attr($name); ?>" value="<?php echo esc_attr($value); ?>" min="<?php echo
    41                                 esc_attr((string) $min); ?>" step="1" class="small-text" <?php disabled(!empty($args['disabled'])); ?> />
     40                <input type="number" autocomplete="off" size="1" id="<?php echo esc_attr($id); ?>" name="<?php echo esc_attr(
     41                        $name,
     42                ); ?>" value="<?php echo esc_attr($value); ?>" min="<?php echo esc_attr(
     43                        (string) $min,
     44                ); ?>" step="1" class="small-text" <?php disabled(!empty($args['disabled'])); ?> />
    4245                <?php
    4346        }
    … …  
    5457                // KeePassXC skip the field.
    5558                ?>
    56                 <input type="text" autocomplete="off" maxlength="<?php echo esc_attr((string) $maxlength); ?>" id="<?php echo
    57                         esc_attr($id); ?>" name="<?php
     59                <input type="text" autocomplete="off" maxlength="<?php echo esc_attr(
     60                        (string) $maxlength,
     61                ); ?>" id="<?php echo esc_attr($id); ?>" name="<?php
    5862
    59                         echo esc_attr($name); ?>" value="<?php echo esc_attr($value); ?>" placeholder="<?php echo
    60                                 esc_attr($placeholder); ?>" class="regular-text code" <?php disabled(!empty($args['disabled'])); ?> />
     63                echo esc_attr($name); ?>" value="<?php echo esc_attr($value); ?>" placeholder="<?php echo esc_attr(
     64                        $placeholder,
     65                ); ?>" class="regular-text code" <?php disabled(!empty($args['disabled'])); ?> />
    6166                <?php
    6267        }
    … …  
    6772                $id = isset($args['id']) ? $args['id'] : '';
    6873                ?>
    69                 <textarea id="<?php echo esc_attr($id); ?>" name="<?php echo
    70                         esc_attr($name); ?>" rows="6" class="large-text code" <?php disabled(!empty($args['disabled'])); ?>><?php echo
    71                                 esc_textarea($value); ?></textarea>
     74                <textarea id="<?php echo esc_attr($id); ?>" name="<?php echo esc_attr(
     75                        $name,
     76                ); ?>" rows="6" class="large-text code" <?php disabled(!empty($args['disabled'])); ?>><?php echo esc_textarea(
     77                        $value,
     78                ); ?></textarea>
    7279                <p class="description"><?php esc_html_e(
    7380                        'One regex fragment per line, matched against the request URI.',
  • yuracode-security/tags/1.0.6/src/Admin/SettingsPage.php

    r3714465 r3753699  
    1010        const OPTION_GROUP = 'yuracode-security_settings_group';
    1111        const PAGE = 'yuracode-security';
     12
     13        // Placeholder tab on subsites of a multisite network: the firewall is
     14        // network-managed, so its real sections collapse into this read-only one.
     15        const NETWORK_FIREWALL_SECTION = 'yuracode-security_network_firewall';
    1216
    1317        public static function init() {
    … …  
    6266                $defaults = Plugin::defaults();
    6367                $input = is_array($input) ? $input : array();
     68                $manages_file = HtaccessManager::current_site_manages_file();
    6469                $out = array();
    6570
    6671                foreach (Schema::fields() as $field) {
    6772                        list($section, $key, $label, $type, $args) = $field;
     73
     74                        // The firewall is network-managed on multisite: subsites don't
     75                        // render its fields, so its keys are dropped here, exactly like
     76                        // Pro keys after a license lapse.
     77                        if (!$manages_file && self::is_firewall_section($section)) {
     78                                continue;
     79                        }
    6880
    6981                        $path = explode('.', $args['path']);
    … …  
    92104
    93105        public static function htaccess_notice() {
    94                 if (!self::on_settings_page()) {
     106                if (!self::on_settings_page() || !HtaccessManager::current_site_manages_file()) {
    95107                        return;
    96108                }
    … …  
    112124
    113125        public static function htaccess_server_notice() {
    114                 if (!self::on_settings_page()) {
     126                if (!self::on_settings_page() || !HtaccessManager::current_site_manages_file()) {
    115127                        return;
    116128                }
    … …  
    147159         */
    148160        public static function rewrite_block_notice() {
    149                 if (!self::on_settings_page() || !current_user_can('manage_options')) {
     161                if (
     162                        !self::on_settings_page()
     163                        || !current_user_can('manage_options')
     164                        || !HtaccessManager::current_site_manages_file()
     165                ) {
    150166                        return;
    151167                }
    … …  
    175191                // Direct links to a tab (?ycs_tab=<id>) only activate known schema
    176192                // sections or extra tabs; anything else falls back to the first tab.
    177                 $known_tabs = array_merge(array_keys(Schema::sections()), array_keys($extra_tabs));
     193                $known_tabs = array_merge(array_keys(self::visible_sections()), array_keys($extra_tabs));
    178194
    179195                if (!in_array($active_tab, $known_tabs, true)) {
    … …  
    211227                $first = true;
    212228
    213                 foreach (Schema::sections() as $id => $section): ?>
     229                foreach (self::visible_sections() as $id => $section): ?>
    214230                        <button
    215231                                type="button"
    … …  
    248264                $first = true;
    249265
    250                 foreach (Schema::sections() as $id => $section): ?>
     266                foreach (self::visible_sections() as $id => $section): ?>
    251267                        <div
    252268                                id="panel-<?php echo esc_attr($id); ?>"
    … …  
    263279                                        </div>
    264280                                        <div class="ycs-fields">
    265                                                 <?php FieldRenderer::render_fields($id); ?>
     281                                                <?php
     282
     283                                                if (self::NETWORK_FIREWALL_SECTION === $id) {
     284                                                        echo
     285                                                                '<p class="description">'
     286                                                                        . esc_html__(
     287                                                                                'The firewall rules live in the one .htaccess file in the WordPress root that every site on this network shares, so they are configured on the main site. Open Settings > YuraCode Security there. The hardening and login-protection settings on this page apply to this site only.',
     288                                                                                'yuracode-security',
     289                                                                        )
     290                                                                        . '</p>';
     291                                                } else {
     292                                                        FieldRenderer::render_fields($id);
     293                                                }
     294                                                ?>
    266295                                        </div>
    267296                                </div>
    … …  
    295324        }
    296325
     326        private static function visible_sections() {
     327                return self::sections_for_context(Schema::sections(), HtaccessManager::current_site_manages_file());
     328        }
     329
     330        /**
     331         * Sections as the current context shows them; pure, so the multisite
     332         * branch stays testable on a single-site install.
     333         *
     334         * On a subsite of a multisite network the firewall sections collapse
     335         * into a single read-only tab: the rules live in the one .htaccess the
     336         * network shares and are managed on the main site.
     337         *
     338         * @param array $sections     Schema sections, id => section.
     339         * @param bool  $site_manages Whether the current site writes the .htaccess.
     340         */
     341        public static function sections_for_context($sections, $site_manages) {
     342                if ($site_manages) {
     343                        return $sections;
     344                }
     345
     346                $out = array();
     347
     348                foreach ((array) $sections as $id => $section) {
     349                        if (self::is_firewall_section($id)) {
     350                                continue;
     351                        }
     352
     353                        $out[$id] = $section;
     354                }
     355
     356                $out[self::NETWORK_FIREWALL_SECTION] = array(
     357                        'title' => __('Firewall', 'yuracode-security'),
     358                        'desc' => __(
     359                                'The firewall protects the entire network and is managed on the main site.',
     360                                'yuracode-security',
     361                        ),
     362                );
     363
     364                return $out;
     365        }
     366
     367        /**
     368         * Whether a schema section belongs to the network-managed firewall.
     369         *
     370         * Add-ons registering firewall sections reuse the `yuracode-security_firewall*`
     371         * prefix, so they collapse on subsites too.
     372         */
     373        private static function is_firewall_section($id) {
     374                return str_starts_with((string) $id, 'yuracode-security_firewall');
     375        }
     376
    297377        private static function schema_tab_active($id, $active_tab, $first) {
    298378                // No tab selected (or an unknown/extra tab): the first schema tab wins.
  • yuracode-security/tags/1.0.6/src/Core/Firewall.php

    r3696012 r3753699  
    127127                                        'path' => 'firewall.protect_config',
    128128                                        'tip' => __(
    129                                                 'Denies direct access to wp-config.php, php.ini, error_log, and .sql / .log backups. On by default.',
     129                                                'Denies direct access to wp-config.php and error_log, to database dumps and logs (.sql, .log and their archives), to backup and temp files (.bak, .old, .tmp, .swp, .save, .backup, .ini), and to readme.html / license.txt, which leak your WordPress version. On by default.',
    130130                                                'yuracode-security',
    131131                                        ),
  • yuracode-security/tags/1.0.6/src/Firewall/HtaccessManager.php

    r3714465 r3753699  
    55use YuraCodeSecurity\Plugin;
    66
    7 // @mago-ignore lint:use-wp-functions
     7// One class owns the whole .htaccess lifecycle (guard, compare, write,
     8// backup, uploads, cleanup); splitting it would scatter one file's concerns.
     9// @mago-ignore lint:use-wp-functions, cyclomatic-complexity
    810class HtaccessManager {
    911        const MARKER_START = '# BEGIN YuraCode Security';
    … …  
    1315
    1416        public static function apply_settings($old = null, $new = null) {
     17                // On multisite the root .htaccess is one file shared by the whole
     18                // network: only the main site's settings may write it.
     19                if (!self::current_site_manages_file()) {
     20                        return;
     21                }
     22
    1523                if (!self::should_apply($old, $new)) {
    1624                        return;
    … …  
    4351
    4452                if (!empty($firewall['protect_uploads'])) {
    45                         self::write_uploads();
     53                        self::apply_uploads_to_all_sites(true);
    4654                } else {
    47                         self::remove_uploads();
     55                        self::apply_uploads_to_all_sites(false);
    4856                }
    4957        }
    … …  
    132140
    133141        public static function remove() {
     142                if (!self::current_site_manages_file()) {
     143                        return;
     144                }
     145
    134146                $path = self::path();
    135147
    … …  
    152164                }
    153165
    154                 self::remove_uploads();
     166                self::apply_uploads_to_all_sites(false);
    155167        }
    156168
    … …  
    177189        }
    178190
     191        /**
     192         * Apply or drop the uploads guard on every site of the network.
     193         *
     194         * Each site has its own uploads directory (uploads/sites/<id> on
     195         * multisite), but the toggle lives in the main site's firewall settings,
     196         * so one save fans out to all sites. Called only from the managing
     197         * (main) site's context.
     198         *
     199         * @param bool $protect Whether to write (true) or remove (false) the guard.
     200         */
     201        private static function apply_uploads_to_all_sites($protect) {
     202                $apply = $protect ? 'write_uploads' : 'remove_uploads';
     203
     204                self::{$apply}();
     205
     206                if (!is_multisite()) {
     207                        return;
     208                }
     209
     210                foreach ((array) get_sites(array('fields' => 'ids', 'number' => 0)) as $site_id) {
     211                        if (is_main_site((int) $site_id)) {
     212                                continue;
     213                        }
     214
     215                        switch_to_blog((int) $site_id);
     216                        self::{$apply}();
     217                        restore_current_blog();
     218                }
     219        }
     220
    179221        public static function path() {
    180222                return untrailingslashit(ABSPATH) . '/.htaccess';
     223        }
     224
     225        /**
     226         * Whether the current site is the one whose settings drive the shared files.
     227         *
     228         * On a multisite network the root .htaccess is a single file shared by
     229         * every site, so only the main site's settings may write it; subsites
     230         * show the firewall read-only and never write. Single-site installs
     231         * always manage their own file.
     232         */
     233        public static function current_site_manages_file() {
     234                return !is_multisite() || is_main_site();
    181235        }
    182236
  • yuracode-security/tags/1.0.6/src/Firewall/Rules8G.php

    r3703514 r3753699  
    144144                        $out[] = '</Files>';
    145145                        $out[] = '';
    146                         $out[] = '<FilesMatch "^(wp-config\\.php|php\\.ini|error_log|.*\\.(sql|log)(\\.(tar|gz|7z|rar|bz2|zip))?)$">';
     146                        // readme.html / license.txt / licencia.txt leak the WordPress
     147                        // version to anyone who fetches them; .htaccess and .htpasswd are
     148                        // already denied by Apache's own <FilesMatch "^\.ht">.
     149                        $out[] = '<FilesMatch "^(wp-config\\.php|php\\.ini|error_log|readme\\.html|license\\.txt|licencia\\.txt|.*\\.(sql|log)(\\.(tar|gz|7z|rar|bz2|zip))?)$">';
     150                        $out[] = "\tRequire all denied";
     151                        $out[] = '</FilesMatch>';
     152                        $out[] = '';
     153                        $out[] = '<FilesMatch "\.(bak|old|tmp|swp|save|backup|ini)$">';
    147154                        $out[] = "\tRequire all denied";
    148155                        $out[] = '</FilesMatch>';
  • yuracode-security/tags/1.0.6/src/Plugin.php

    r3713443 r3753699  
    6363                }
    6464
     65                if (is_multisite()) {
     66                        add_action('wp_initialize_site', array(self::class, 'initialize_site'), 10, 2);
     67                }
     68
    6569                SettingsPage::init();
    6670
    … …  
    6872        }
    6973
    70         public static function activate() {
    71                 if (false === get_option(self::OPTION)) {
    72                         add_option(self::OPTION, self::defaults());
     74        /**
     75         * Activate for one site, or for the whole network when WordPress passes
     76         * $network_wide (network activation fires this once, in the main site's
     77         * context, so every site's option row must be seeded explicitly).
     78         *
     79         * @param bool $network_wide Whether activation covers the whole network.
     80         */
     81        public static function activate($network_wide = false) {
     82                if ($network_wide && is_multisite()) {
     83                        foreach ((array) get_sites(array('fields' => 'ids', 'number' => 0)) as $site_id) {
     84                                switch_to_blog((int) $site_id);
     85                                self::seed_settings();
     86                                restore_current_blog();
     87                        }
     88                } else {
     89                        self::seed_settings();
    7390                }
    7491
    … …  
    7693        }
    7794
    78         public static function deactivate() {
     95        /**
     96         * Deactivate for one site, or for the whole network. The shared .htaccess
     97         * block is removed once, from the managing (main) site only;
     98         * HtaccessManager enforces that itself.
     99         *
     100         * @param bool $network_wide Whether deactivation covers the whole network.
     101         */
     102        public static function deactivate($network_wide = false) {
    79103                HtaccessManager::remove();
    80                 delete_transient(Ip::key());
     104
     105                if ($network_wide && is_multisite()) {
     106                        foreach ((array) get_sites(array('fields' => 'ids', 'number' => 0)) as $site_id) {
     107                                switch_to_blog((int) $site_id);
     108                                delete_transient(Ip::key());
     109                                restore_current_blog();
     110                        }
     111                } else {
     112                        delete_transient(Ip::key());
     113                }
     114        }
     115
     116        private static function seed_settings() {
     117                if (false === get_option(self::OPTION)) {
     118                        add_option(self::OPTION, self::defaults());
     119                }
     120        }
     121
     122        /**
     123         * Give a newly created network site the same state as a site that was
     124         * present at network activation: a seeded settings option and, while the
     125         * firewall's uploads guard is on, a guard in its own uploads directory.
     126         *
     127         * Only acts while the plugin is network-active; per-site activations
     128         * have nothing to do with site creation.
     129         *
     130         * @param \WP_Site $new_site The site being created.
     131         */
     132        public static function initialize_site($new_site) {
     133                $network_plugins = get_site_option('active_sitewide_plugins', array());
     134                $active = array_keys(is_array($network_plugins) ? $network_plugins : array());
     135
     136                if (!in_array(plugin_basename(YURACODESECURITY_FILE), $active, true)) {
     137                        return;
     138                }
     139
     140                // The main site drives the network-wide firewall state.
     141                $saved = get_blog_option((int) get_main_site_id(), self::OPTION);
     142                $settings = array_replace_recursive(self::defaults(), is_array($saved) ? $saved : array());
     143
     144                switch_to_blog((int) $new_site->blog_id);
     145                self::seed_settings();
     146
     147                if (!empty($settings['firewall']['protect_uploads']) && HtaccessManager::server_supports_htaccess()) {
     148                        HtaccessManager::write_uploads();
     149                }
     150
     151                restore_current_blog();
    81152        }
    82153
  • yuracode-security/tags/1.0.6/uninstall.php

    r3696012 r3753699  
    99\YuraCodeSecurity\Autoloader::register();
    1010
     11// The .htaccess block and its backups are one per network on multisite:
     12// remove them once, from the managing (main) site. HtaccessManager skips
     13// the removal when uninstall runs on a subsite.
    1114\YuraCodeSecurity\Firewall\HtaccessManager::remove();
    12 
    1315\YuraCodeSecurity\Firewall\HtaccessManager::cleanup_backups();
    14 
    15 delete_option( 'yuracode-security_settings' );
    1616
    1717global $wpdb;
    1818
    19 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- clearing transients by prefix has no WP API; standard uninstall pattern.
    20 $wpdb->query(
    21         $wpdb->prepare(
    22                 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
    23                 '_transient_yuracode-security_%',
    24                 '_transient_timeout_yuracode-security_%'
    25         )
    26 );
     19// Settings and transients live per site, so sweep every site of the
     20// network; on a single-site install this is one plain pass.
     21$site_ids = is_multisite() ? get_sites( array( 'fields' => 'ids', 'number' => 0 ) ) : array( null );
     22
     23foreach ( $site_ids as $site_id ) {
     24        if ( null !== $site_id ) {
     25                switch_to_blog( (int) $site_id );
     26        }
     27
     28        delete_option( 'yuracode-security_settings' );
     29
     30        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- clearing transients by prefix has no WP API; standard uninstall pattern.
     31        $wpdb->query(
     32                $wpdb->prepare(
     33                        "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
     34                        '_transient_yuracode-security_%',
     35                        '_transient_timeout_yuracode-security_%'
     36                )
     37        );
     38
     39        if ( null !== $site_id ) {
     40                restore_current_blog();
     41        }
     42}
  • yuracode-security/tags/1.0.6/yuracode-security.php

    r3714465 r3753699  
    44 * Plugin URI:        https://security.yuraco.de/wordpress/
    55 * Description:       Lightweight hardening, brute-force login protection, and an Apache .htaccess firewall (8G ruleset) for WordPress.
    6  * Version:           1.0.5
     6 * Version:           1.0.6
    77 * Author:            YuraCode
    88 * License:           GPL-2.0-or-later
    … …  
    1616}
    1717
    18 define( 'YURACODESECURITY_VERSION', '1.0.5' );
     18define( 'YURACODESECURITY_VERSION', '1.0.6' );
    1919define( 'YURACODESECURITY_FILE', __FILE__ );
    2020define( 'YURACODESECURITY_DIR', plugin_dir_path( __FILE__ ) );
  • yuracode-security/trunk/readme.txt

    r3714465 r3753699  
    55Tested up to: 7.1
    66Requires PHP: 8.0
    7 Stable tag: 1.0.5
     7Stable tag: 1.0.6
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    3232Think of this as a bouncer at the door of your website. It checks every visitor before your site even loads and kicks out the troublemakers.
    3333* **Blocks malicious visitors:** Recognizes known attack patterns and blocks them immediately.
    34 * **Protects your private files:** Ensures sensitive files (like your site's database settings or backups) can't be downloaded by strangers.
     34* **Protects your private files:** Ensures sensitive files (like your site's database settings, backups, log files, and the WordPress version files) can't be downloaded by strangers.
    3535* **Stops malicious uploads:** If an attacker manages to upload a file, this prevents the file from actually running any harmful code on your server.
    3636
    … …  
    6969* Sends conservative response headers on the front end: `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, and `X-Frame-Options: SAMEORIGIN`. Each is only sent when nothing else has already set it.
    7070* Login protection throttles an IP address after too many failed attempts.
    71 * The firewall uses the 8G ruleset by Perishable Press, written to `.htaccess` and the uploads folder, to block malicious query strings, request URIs, user agents, and referrers. It also protects `wp-config.php` and blocks PHP execution in `/uploads`.
     71* The firewall uses the 8G ruleset by Perishable Press, written to `.htaccess` and the uploads folder, to block malicious query strings, request URIs, user agents, and referrers. It also protects `wp-config.php`, denies public access to backup, dump, and log files (`.bak`, `.old`, `.tmp`, `.swp`, `.save`, `.backup`, `.ini`, `.sql`, `.log`) as well as `readme.html` and `license.txt`, and blocks PHP execution in `/uploads`.
    7272
    7373== Privacy ==
    … …  
    8686
    8787== Changelog ==
     88
     89= 1.0.6 =
     90* Multisite networks: the main site now drives the shared firewall, and direct access to readme.html, license.txt and licencia.txt is blocked.
    8891
    8992= 1.0.5 =
  • yuracode-security/trunk/src/Admin/FieldRenderer.php

    r3714465 r3753699  
    1919                ?>
    2020                <label class="ycs-switch">
    21                         <input type="checkbox" id="<?php echo esc_attr($id); ?>" name="<?php echo
    22                                 esc_attr($name); ?>" value="1" <?php checked(!empty($value)); ?> <?php disabled(!empty($args['disabled'])); ?> />
     21                        <input type="checkbox" id="<?php echo esc_attr($id); ?>" name="<?php echo esc_attr(
     22                                $name,
     23                        ); ?>" value="1" <?php checked(!empty($value)); ?> <?php disabled(!empty($args['disabled'])); ?> />
    2324                        <span class="ycs-switch__slider"></span>
    2425                </label>
    … …  
    3738                // field. The visible width comes from WP's .small-text, not `size`.
    3839                ?>
    39                 <input type="number" autocomplete="off" size="1" id="<?php echo esc_attr($id); ?>" name="<?php echo
    40                         esc_attr($name); ?>" value="<?php echo esc_attr($value); ?>" min="<?php echo
    41                                 esc_attr((string) $min); ?>" step="1" class="small-text" <?php disabled(!empty($args['disabled'])); ?> />
     40                <input type="number" autocomplete="off" size="1" id="<?php echo esc_attr($id); ?>" name="<?php echo esc_attr(
     41                        $name,
     42                ); ?>" value="<?php echo esc_attr($value); ?>" min="<?php echo esc_attr(
     43                        (string) $min,
     44                ); ?>" step="1" class="small-text" <?php disabled(!empty($args['disabled'])); ?> />
    4245                <?php
    4346        }
    … …  
    5457                // KeePassXC skip the field.
    5558                ?>
    56                 <input type="text" autocomplete="off" maxlength="<?php echo esc_attr((string) $maxlength); ?>" id="<?php echo
    57                         esc_attr($id); ?>" name="<?php
     59                <input type="text" autocomplete="off" maxlength="<?php echo esc_attr(
     60                        (string) $maxlength,
     61                ); ?>" id="<?php echo esc_attr($id); ?>" name="<?php
    5862
    59                         echo esc_attr($name); ?>" value="<?php echo esc_attr($value); ?>" placeholder="<?php echo
    60                                 esc_attr($placeholder); ?>" class="regular-text code" <?php disabled(!empty($args['disabled'])); ?> />
     63                echo esc_attr($name); ?>" value="<?php echo esc_attr($value); ?>" placeholder="<?php echo esc_attr(
     64                        $placeholder,
     65                ); ?>" class="regular-text code" <?php disabled(!empty($args['disabled'])); ?> />
    6166                <?php
    6267        }
    … …  
    6772                $id = isset($args['id']) ? $args['id'] : '';
    6873                ?>
    69                 <textarea id="<?php echo esc_attr($id); ?>" name="<?php echo
    70                         esc_attr($name); ?>" rows="6" class="large-text code" <?php disabled(!empty($args['disabled'])); ?>><?php echo
    71                                 esc_textarea($value); ?></textarea>
     74                <textarea id="<?php echo esc_attr($id); ?>" name="<?php echo esc_attr(
     75                        $name,
     76                ); ?>" rows="6" class="large-text code" <?php disabled(!empty($args['disabled'])); ?>><?php echo esc_textarea(
     77                        $value,
     78                ); ?></textarea>
    7279                <p class="description"><?php esc_html_e(
    7380                        'One regex fragment per line, matched against the request URI.',
  • yuracode-security/trunk/src/Admin/SettingsPage.php

    r3714465 r3753699  
    1010        const OPTION_GROUP = 'yuracode-security_settings_group';
    1111        const PAGE = 'yuracode-security';
     12
     13        // Placeholder tab on subsites of a multisite network: the firewall is
     14        // network-managed, so its real sections collapse into this read-only one.
     15        const NETWORK_FIREWALL_SECTION = 'yuracode-security_network_firewall';
    1216
    1317        public static function init() {
    … …  
    6266                $defaults = Plugin::defaults();
    6367                $input = is_array($input) ? $input : array();
     68                $manages_file = HtaccessManager::current_site_manages_file();
    6469                $out = array();
    6570
    6671                foreach (Schema::fields() as $field) {
    6772                        list($section, $key, $label, $type, $args) = $field;
     73
     74                        // The firewall is network-managed on multisite: subsites don't
     75                        // render its fields, so its keys are dropped here, exactly like
     76                        // Pro keys after a license lapse.
     77                        if (!$manages_file && self::is_firewall_section($section)) {
     78                                continue;
     79                        }
    6880
    6981                        $path = explode('.', $args['path']);
    … …  
    92104
    93105        public static function htaccess_notice() {
    94                 if (!self::on_settings_page()) {
     106                if (!self::on_settings_page() || !HtaccessManager::current_site_manages_file()) {
    95107                        return;
    96108                }
    … …  
    112124
    113125        public static function htaccess_server_notice() {
    114                 if (!self::on_settings_page()) {
     126                if (!self::on_settings_page() || !HtaccessManager::current_site_manages_file()) {
    115127                        return;
    116128                }
    … …  
    147159         */
    148160        public static function rewrite_block_notice() {
    149                 if (!self::on_settings_page() || !current_user_can('manage_options')) {
     161                if (
     162                        !self::on_settings_page()
     163                        || !current_user_can('manage_options')
     164                        || !HtaccessManager::current_site_manages_file()
     165                ) {
    150166                        return;
    151167                }
    … …  
    175191                // Direct links to a tab (?ycs_tab=<id>) only activate known schema
    176192                // sections or extra tabs; anything else falls back to the first tab.
    177                 $known_tabs = array_merge(array_keys(Schema::sections()), array_keys($extra_tabs));
     193                $known_tabs = array_merge(array_keys(self::visible_sections()), array_keys($extra_tabs));
    178194
    179195                if (!in_array($active_tab, $known_tabs, true)) {
    … …  
    211227                $first = true;
    212228
    213                 foreach (Schema::sections() as $id => $section): ?>
     229                foreach (self::visible_sections() as $id => $section): ?>
    214230                        <button
    215231                                type="button"
    … …  
    248264                $first = true;
    249265
    250                 foreach (Schema::sections() as $id => $section): ?>
     266                foreach (self::visible_sections() as $id => $section): ?>
    251267                        <div
    252268                                id="panel-<?php echo esc_attr($id); ?>"
    … …  
    263279                                        </div>
    264280                                        <div class="ycs-fields">
    265                                                 <?php FieldRenderer::render_fields($id); ?>
     281                                                <?php
     282
     283                                                if (self::NETWORK_FIREWALL_SECTION === $id) {
     284                                                        echo
     285                                                                '<p class="description">'
     286                                                                        . esc_html__(
     287                                                                                'The firewall rules live in the one .htaccess file in the WordPress root that every site on this network shares, so they are configured on the main site. Open Settings > YuraCode Security there. The hardening and login-protection settings on this page apply to this site only.',
     288                                                                                'yuracode-security',
     289                                                                        )
     290                                                                        . '</p>';
     291                                                } else {
     292                                                        FieldRenderer::render_fields($id);
     293                                                }
     294                                                ?>
    266295                                        </div>
    267296                                </div>
    … …  
    295324        }
    296325
     326        private static function visible_sections() {
     327                return self::sections_for_context(Schema::sections(), HtaccessManager::current_site_manages_file());
     328        }
     329
     330        /**
     331         * Sections as the current context shows them; pure, so the multisite
     332         * branch stays testable on a single-site install.
     333         *
     334         * On a subsite of a multisite network the firewall sections collapse
     335         * into a single read-only tab: the rules live in the one .htaccess the
     336         * network shares and are managed on the main site.
     337         *
     338         * @param array $sections     Schema sections, id => section.
     339         * @param bool  $site_manages Whether the current site writes the .htaccess.
     340         */
     341        public static function sections_for_context($sections, $site_manages) {
     342                if ($site_manages) {
     343                        return $sections;
     344                }
     345
     346                $out = array();
     347
     348                foreach ((array) $sections as $id => $section) {
     349                        if (self::is_firewall_section($id)) {
     350                                continue;
     351                        }
     352
     353                        $out[$id] = $section;
     354                }
     355
     356                $out[self::NETWORK_FIREWALL_SECTION] = array(
     357                        'title' => __('Firewall', 'yuracode-security'),
     358                        'desc' => __(
     359                                'The firewall protects the entire network and is managed on the main site.',
     360                                'yuracode-security',
     361                        ),
     362                );
     363
     364                return $out;
     365        }
     366
     367        /**
     368         * Whether a schema section belongs to the network-managed firewall.
     369         *
     370         * Add-ons registering firewall sections reuse the `yuracode-security_firewall*`
     371         * prefix, so they collapse on subsites too.
     372         */
     373        private static function is_firewall_section($id) {
     374                return str_starts_with((string) $id, 'yuracode-security_firewall');
     375        }
     376
    297377        private static function schema_tab_active($id, $active_tab, $first) {
    298378                // No tab selected (or an unknown/extra tab): the first schema tab wins.
  • yuracode-security/trunk/src/Core/Firewall.php

    r3696012 r3753699  
    127127                                        'path' => 'firewall.protect_config',
    128128                                        'tip' => __(
    129                                                 'Denies direct access to wp-config.php, php.ini, error_log, and .sql / .log backups. On by default.',
     129                                                'Denies direct access to wp-config.php and error_log, to database dumps and logs (.sql, .log and their archives), to backup and temp files (.bak, .old, .tmp, .swp, .save, .backup, .ini), and to readme.html / license.txt, which leak your WordPress version. On by default.',
    130130                                                'yuracode-security',
    131131                                        ),
  • yuracode-security/trunk/src/Firewall/HtaccessManager.php

    r3714465 r3753699  
    55use YuraCodeSecurity\Plugin;
    66
    7 // @mago-ignore lint:use-wp-functions
     7// One class owns the whole .htaccess lifecycle (guard, compare, write,
     8// backup, uploads, cleanup); splitting it would scatter one file's concerns.
     9// @mago-ignore lint:use-wp-functions, cyclomatic-complexity
    810class HtaccessManager {
    911        const MARKER_START = '# BEGIN YuraCode Security';
    … …  
    1315
    1416        public static function apply_settings($old = null, $new = null) {
     17                // On multisite the root .htaccess is one file shared by the whole
     18                // network: only the main site's settings may write it.
     19                if (!self::current_site_manages_file()) {
     20                        return;
     21                }
     22
    1523                if (!self::should_apply($old, $new)) {
    1624                        return;
    … …  
    4351
    4452                if (!empty($firewall['protect_uploads'])) {
    45                         self::write_uploads();
     53                        self::apply_uploads_to_all_sites(true);
    4654                } else {
    47                         self::remove_uploads();
     55                        self::apply_uploads_to_all_sites(false);
    4856                }
    4957        }
    … …  
    132140
    133141        public static function remove() {
     142                if (!self::current_site_manages_file()) {
     143                        return;
     144                }
     145
    134146                $path = self::path();
    135147
    … …  
    152164                }
    153165
    154                 self::remove_uploads();
     166                self::apply_uploads_to_all_sites(false);
    155167        }
    156168
    … …  
    177189        }
    178190
     191        /**
     192         * Apply or drop the uploads guard on every site of the network.
     193         *
     194         * Each site has its own uploads directory (uploads/sites/<id> on
     195         * multisite), but the toggle lives in the main site's firewall settings,
     196         * so one save fans out to all sites. Called only from the managing
     197         * (main) site's context.
     198         *
     199         * @param bool $protect Whether to write (true) or remove (false) the guard.
     200         */
     201        private static function apply_uploads_to_all_sites($protect) {
     202                $apply = $protect ? 'write_uploads' : 'remove_uploads';
     203
     204                self::{$apply}();
     205
     206                if (!is_multisite()) {
     207                        return;
     208                }
     209
     210                foreach ((array) get_sites(array('fields' => 'ids', 'number' => 0)) as $site_id) {
     211                        if (is_main_site((int) $site_id)) {
     212                                continue;
     213                        }
     214
     215                        switch_to_blog((int) $site_id);
     216                        self::{$apply}();
     217                        restore_current_blog();
     218                }
     219        }
     220
    179221        public static function path() {
    180222                return untrailingslashit(ABSPATH) . '/.htaccess';
     223        }
     224
     225        /**
     226         * Whether the current site is the one whose settings drive the shared files.
     227         *
     228         * On a multisite network the root .htaccess is a single file shared by
     229         * every site, so only the main site's settings may write it; subsites
     230         * show the firewall read-only and never write. Single-site installs
     231         * always manage their own file.
     232         */
     233        public static function current_site_manages_file() {
     234                return !is_multisite() || is_main_site();
    181235        }
    182236
  • yuracode-security/trunk/src/Firewall/Rules8G.php

    r3703514 r3753699  
    144144                        $out[] = '</Files>';
    145145                        $out[] = '';
    146                         $out[] = '<FilesMatch "^(wp-config\\.php|php\\.ini|error_log|.*\\.(sql|log)(\\.(tar|gz|7z|rar|bz2|zip))?)$">';
     146                        // readme.html / license.txt / licencia.txt leak the WordPress
     147                        // version to anyone who fetches them; .htaccess and .htpasswd are
     148                        // already denied by Apache's own <FilesMatch "^\.ht">.
     149                        $out[] = '<FilesMatch "^(wp-config\\.php|php\\.ini|error_log|readme\\.html|license\\.txt|licencia\\.txt|.*\\.(sql|log)(\\.(tar|gz|7z|rar|bz2|zip))?)$">';
     150                        $out[] = "\tRequire all denied";
     151                        $out[] = '</FilesMatch>';
     152                        $out[] = '';
     153                        $out[] = '<FilesMatch "\.(bak|old|tmp|swp|save|backup|ini)$">';
    147154                        $out[] = "\tRequire all denied";
    148155                        $out[] = '</FilesMatch>';
  • yuracode-security/trunk/src/Plugin.php

    r3713443 r3753699  
    6363                }
    6464
     65                if (is_multisite()) {
     66                        add_action('wp_initialize_site', array(self::class, 'initialize_site'), 10, 2);
     67                }
     68
    6569                SettingsPage::init();
    6670
    … …  
    6872        }
    6973
    70         public static function activate() {
    71                 if (false === get_option(self::OPTION)) {
    72                         add_option(self::OPTION, self::defaults());
     74        /**
     75         * Activate for one site, or for the whole network when WordPress passes
     76         * $network_wide (network activation fires this once, in the main site's
     77         * context, so every site's option row must be seeded explicitly).
     78         *
     79         * @param bool $network_wide Whether activation covers the whole network.
     80         */
     81        public static function activate($network_wide = false) {
     82                if ($network_wide && is_multisite()) {
     83                        foreach ((array) get_sites(array('fields' => 'ids', 'number' => 0)) as $site_id) {
     84                                switch_to_blog((int) $site_id);
     85                                self::seed_settings();
     86                                restore_current_blog();
     87                        }
     88                } else {
     89                        self::seed_settings();
    7390                }
    7491
    … …  
    7693        }
    7794
    78         public static function deactivate() {
     95        /**
     96         * Deactivate for one site, or for the whole network. The shared .htaccess
     97         * block is removed once, from the managing (main) site only;
     98         * HtaccessManager enforces that itself.
     99         *
     100         * @param bool $network_wide Whether deactivation covers the whole network.
     101         */
     102        public static function deactivate($network_wide = false) {
    79103                HtaccessManager::remove();
    80                 delete_transient(Ip::key());
     104
     105                if ($network_wide && is_multisite()) {
     106                        foreach ((array) get_sites(array('fields' => 'ids', 'number' => 0)) as $site_id) {
     107                                switch_to_blog((int) $site_id);
     108                                delete_transient(Ip::key());
     109                                restore_current_blog();
     110                        }
     111                } else {
     112                        delete_transient(Ip::key());
     113                }
     114        }
     115
     116        private static function seed_settings() {
     117                if (false === get_option(self::OPTION)) {
     118                        add_option(self::OPTION, self::defaults());
     119                }
     120        }
     121
     122        /**
     123         * Give a newly created network site the same state as a site that was
     124         * present at network activation: a seeded settings option and, while the
     125         * firewall's uploads guard is on, a guard in its own uploads directory.
     126         *
     127         * Only acts while the plugin is network-active; per-site activations
     128         * have nothing to do with site creation.
     129         *
     130         * @param \WP_Site $new_site The site being created.
     131         */
     132        public static function initialize_site($new_site) {
     133                $network_plugins = get_site_option('active_sitewide_plugins', array());
     134                $active = array_keys(is_array($network_plugins) ? $network_plugins : array());
     135
     136                if (!in_array(plugin_basename(YURACODESECURITY_FILE), $active, true)) {
     137                        return;
     138                }
     139
     140                // The main site drives the network-wide firewall state.
     141                $saved = get_blog_option((int) get_main_site_id(), self::OPTION);
     142                $settings = array_replace_recursive(self::defaults(), is_array($saved) ? $saved : array());
     143
     144                switch_to_blog((int) $new_site->blog_id);
     145                self::seed_settings();
     146
     147                if (!empty($settings['firewall']['protect_uploads']) && HtaccessManager::server_supports_htaccess()) {
     148                        HtaccessManager::write_uploads();
     149                }
     150
     151                restore_current_blog();
    81152        }
    82153
  • yuracode-security/trunk/uninstall.php

    r3696012 r3753699  
    99\YuraCodeSecurity\Autoloader::register();
    1010
     11// The .htaccess block and its backups are one per network on multisite:
     12// remove them once, from the managing (main) site. HtaccessManager skips
     13// the removal when uninstall runs on a subsite.
    1114\YuraCodeSecurity\Firewall\HtaccessManager::remove();
    12 
    1315\YuraCodeSecurity\Firewall\HtaccessManager::cleanup_backups();
    14 
    15 delete_option( 'yuracode-security_settings' );
    1616
    1717global $wpdb;
    1818
    19 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- clearing transients by prefix has no WP API; standard uninstall pattern.
    20 $wpdb->query(
    21         $wpdb->prepare(
    22                 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
    23                 '_transient_yuracode-security_%',
    24                 '_transient_timeout_yuracode-security_%'
    25         )
    26 );
     19// Settings and transients live per site, so sweep every site of the
     20// network; on a single-site install this is one plain pass.
     21$site_ids = is_multisite() ? get_sites( array( 'fields' => 'ids', 'number' => 0 ) ) : array( null );
     22
     23foreach ( $site_ids as $site_id ) {
     24        if ( null !== $site_id ) {
     25                switch_to_blog( (int) $site_id );
     26        }
     27
     28        delete_option( 'yuracode-security_settings' );
     29
     30        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- clearing transients by prefix has no WP API; standard uninstall pattern.
     31        $wpdb->query(
     32                $wpdb->prepare(
     33                        "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
     34                        '_transient_yuracode-security_%',
     35                        '_transient_timeout_yuracode-security_%'
     36                )
     37        );
     38
     39        if ( null !== $site_id ) {
     40                restore_current_blog();
     41        }
     42}
  • yuracode-security/trunk/yuracode-security.php

    r3714465 r3753699  
    44 * Plugin URI:        https://security.yuraco.de/wordpress/
    55 * Description:       Lightweight hardening, brute-force login protection, and an Apache .htaccess firewall (8G ruleset) for WordPress.
    6  * Version:           1.0.5
     6 * Version:           1.0.6
    77 * Author:            YuraCode
    88 * License:           GPL-2.0-or-later
    … …  
    1616}
    1717
    18 define( 'YURACODESECURITY_VERSION', '1.0.5' );
     18define( 'YURACODESECURITY_VERSION', '1.0.6' );
    1919define( 'YURACODESECURITY_FILE', __FILE__ );
    2020define( 'YURACODESECURITY_DIR', plugin_dir_path( __FILE__ ) );
Note: See TracChangeset for help on using the changeset viewer.