Changeset 3753699
- Timestamp:
- 10/18/2026 11:12:11 PM (2 hours ago)
- Location:
- yuracode-security
- Files:
-
- 18 edited
- 1 copied
-
tags/1.0.6 (copied) (copied from yuracode-security/trunk )
-
tags/1.0.6/readme.txt (modified) (4 diffs)
-
tags/1.0.6/src/Admin/FieldRenderer.php (modified) (4 diffs)
-
tags/1.0.6/src/Admin/SettingsPage.php (modified) (10 diffs)
-
tags/1.0.6/src/Core/Firewall.php (modified) (1 diff)
-
tags/1.0.6/src/Firewall/HtaccessManager.php (modified) (6 diffs)
-
tags/1.0.6/src/Firewall/Rules8G.php (modified) (1 diff)
-
tags/1.0.6/src/Plugin.php (modified) (3 diffs)
-
tags/1.0.6/uninstall.php (modified) (1 diff)
-
tags/1.0.6/yuracode-security.php (modified) (2 diffs)
-
trunk/readme.txt (modified) (4 diffs)
-
trunk/src/Admin/FieldRenderer.php (modified) (4 diffs)
-
trunk/src/Admin/SettingsPage.php (modified) (10 diffs)
-
trunk/src/Core/Firewall.php (modified) (1 diff)
-
trunk/src/Firewall/HtaccessManager.php (modified) (6 diffs)
-
trunk/src/Firewall/Rules8G.php (modified) (1 diff)
-
trunk/src/Plugin.php (modified) (3 diffs)
-
trunk/uninstall.php (modified) (1 diff)
-
trunk/yuracode-security.php (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
yuracode-security/tags/1.0.6/readme.txt
r3714465 r3753699 5 5 Tested up to: 7.1 6 6 Requires PHP: 8.0 7 Stable tag: 1.0. 57 Stable tag: 1.0.6 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 32 32 Think 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. 33 33 * **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. 35 35 * **Stops malicious uploads:** If an attacker manages to upload a file, this prevents the file from actually running any harmful code on your server. 36 36 … … 69 69 * 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. 70 70 * 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`. 72 72 73 73 == Privacy == … … 86 86 87 87 == 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. 88 91 89 92 = 1.0.5 = -
yuracode-security/tags/1.0.6/src/Admin/FieldRenderer.php
r3714465 r3753699 19 19 ?> 20 20 <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'])); ?> /> 23 24 <span class="ycs-switch__slider"></span> 24 25 </label> … … 37 38 // field. The visible width comes from WP's .small-text, not `size`. 38 39 ?> 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'])); ?> /> 42 45 <?php 43 46 } … … 54 57 // KeePassXC skip the field. 55 58 ?> 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 58 62 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'])); ?> /> 61 66 <?php 62 67 } … … 67 72 $id = isset($args['id']) ? $args['id'] : ''; 68 73 ?> 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> 72 79 <p class="description"><?php esc_html_e( 73 80 'One regex fragment per line, matched against the request URI.', -
yuracode-security/tags/1.0.6/src/Admin/SettingsPage.php
r3714465 r3753699 10 10 const OPTION_GROUP = 'yuracode-security_settings_group'; 11 11 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'; 12 16 13 17 public static function init() { … … 62 66 $defaults = Plugin::defaults(); 63 67 $input = is_array($input) ? $input : array(); 68 $manages_file = HtaccessManager::current_site_manages_file(); 64 69 $out = array(); 65 70 66 71 foreach (Schema::fields() as $field) { 67 72 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 } 68 80 69 81 $path = explode('.', $args['path']); … … 92 104 93 105 public static function htaccess_notice() { 94 if (!self::on_settings_page() ) {106 if (!self::on_settings_page() || !HtaccessManager::current_site_manages_file()) { 95 107 return; 96 108 } … … 112 124 113 125 public static function htaccess_server_notice() { 114 if (!self::on_settings_page() ) {126 if (!self::on_settings_page() || !HtaccessManager::current_site_manages_file()) { 115 127 return; 116 128 } … … 147 159 */ 148 160 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 ) { 150 166 return; 151 167 } … … 175 191 // Direct links to a tab (?ycs_tab=<id>) only activate known schema 176 192 // 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)); 178 194 179 195 if (!in_array($active_tab, $known_tabs, true)) { … … 211 227 $first = true; 212 228 213 foreach ( Schema::sections() as $id => $section): ?>229 foreach (self::visible_sections() as $id => $section): ?> 214 230 <button 215 231 type="button" … … 248 264 $first = true; 249 265 250 foreach ( Schema::sections() as $id => $section): ?>266 foreach (self::visible_sections() as $id => $section): ?> 251 267 <div 252 268 id="panel-<?php echo esc_attr($id); ?>" … … 263 279 </div> 264 280 <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 ?> 266 295 </div> 267 296 </div> … … 295 324 } 296 325 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 297 377 private static function schema_tab_active($id, $active_tab, $first) { 298 378 // 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 127 127 'path' => 'firewall.protect_config', 128 128 '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.', 130 130 'yuracode-security', 131 131 ), -
yuracode-security/tags/1.0.6/src/Firewall/HtaccessManager.php
r3714465 r3753699 5 5 use YuraCodeSecurity\Plugin; 6 6 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 8 10 class HtaccessManager { 9 11 const MARKER_START = '# BEGIN YuraCode Security'; … … 13 15 14 16 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 15 23 if (!self::should_apply($old, $new)) { 16 24 return; … … 43 51 44 52 if (!empty($firewall['protect_uploads'])) { 45 self:: write_uploads();53 self::apply_uploads_to_all_sites(true); 46 54 } else { 47 self:: remove_uploads();55 self::apply_uploads_to_all_sites(false); 48 56 } 49 57 } … … 132 140 133 141 public static function remove() { 142 if (!self::current_site_manages_file()) { 143 return; 144 } 145 134 146 $path = self::path(); 135 147 … … 152 164 } 153 165 154 self:: remove_uploads();166 self::apply_uploads_to_all_sites(false); 155 167 } 156 168 … … 177 189 } 178 190 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 179 221 public static function path() { 180 222 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(); 181 235 } 182 236 -
yuracode-security/tags/1.0.6/src/Firewall/Rules8G.php
r3703514 r3753699 144 144 $out[] = '</Files>'; 145 145 $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)$">'; 147 154 $out[] = "\tRequire all denied"; 148 155 $out[] = '</FilesMatch>'; -
yuracode-security/tags/1.0.6/src/Plugin.php
r3713443 r3753699 63 63 } 64 64 65 if (is_multisite()) { 66 add_action('wp_initialize_site', array(self::class, 'initialize_site'), 10, 2); 67 } 68 65 69 SettingsPage::init(); 66 70 … … 68 72 } 69 73 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(); 73 90 } 74 91 … … 76 93 } 77 94 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) { 79 103 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(); 81 152 } 82 153 -
yuracode-security/tags/1.0.6/uninstall.php
r3696012 r3753699 9 9 \YuraCodeSecurity\Autoloader::register(); 10 10 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. 11 14 \YuraCodeSecurity\Firewall\HtaccessManager::remove(); 12 13 15 \YuraCodeSecurity\Firewall\HtaccessManager::cleanup_backups(); 14 15 delete_option( 'yuracode-security_settings' );16 16 17 17 global $wpdb; 18 18 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 23 foreach ( $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 4 4 * Plugin URI: https://security.yuraco.de/wordpress/ 5 5 * Description: Lightweight hardening, brute-force login protection, and an Apache .htaccess firewall (8G ruleset) for WordPress. 6 * Version: 1.0. 56 * Version: 1.0.6 7 7 * Author: YuraCode 8 8 * License: GPL-2.0-or-later … … 16 16 } 17 17 18 define( 'YURACODESECURITY_VERSION', '1.0. 5' );18 define( 'YURACODESECURITY_VERSION', '1.0.6' ); 19 19 define( 'YURACODESECURITY_FILE', __FILE__ ); 20 20 define( 'YURACODESECURITY_DIR', plugin_dir_path( __FILE__ ) ); -
yuracode-security/trunk/readme.txt
r3714465 r3753699 5 5 Tested up to: 7.1 6 6 Requires PHP: 8.0 7 Stable tag: 1.0. 57 Stable tag: 1.0.6 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 32 32 Think 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. 33 33 * **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. 35 35 * **Stops malicious uploads:** If an attacker manages to upload a file, this prevents the file from actually running any harmful code on your server. 36 36 … … 69 69 * 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. 70 70 * 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`. 72 72 73 73 == Privacy == … … 86 86 87 87 == 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. 88 91 89 92 = 1.0.5 = -
yuracode-security/trunk/src/Admin/FieldRenderer.php
r3714465 r3753699 19 19 ?> 20 20 <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'])); ?> /> 23 24 <span class="ycs-switch__slider"></span> 24 25 </label> … … 37 38 // field. The visible width comes from WP's .small-text, not `size`. 38 39 ?> 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'])); ?> /> 42 45 <?php 43 46 } … … 54 57 // KeePassXC skip the field. 55 58 ?> 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 58 62 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'])); ?> /> 61 66 <?php 62 67 } … … 67 72 $id = isset($args['id']) ? $args['id'] : ''; 68 73 ?> 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> 72 79 <p class="description"><?php esc_html_e( 73 80 'One regex fragment per line, matched against the request URI.', -
yuracode-security/trunk/src/Admin/SettingsPage.php
r3714465 r3753699 10 10 const OPTION_GROUP = 'yuracode-security_settings_group'; 11 11 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'; 12 16 13 17 public static function init() { … … 62 66 $defaults = Plugin::defaults(); 63 67 $input = is_array($input) ? $input : array(); 68 $manages_file = HtaccessManager::current_site_manages_file(); 64 69 $out = array(); 65 70 66 71 foreach (Schema::fields() as $field) { 67 72 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 } 68 80 69 81 $path = explode('.', $args['path']); … … 92 104 93 105 public static function htaccess_notice() { 94 if (!self::on_settings_page() ) {106 if (!self::on_settings_page() || !HtaccessManager::current_site_manages_file()) { 95 107 return; 96 108 } … … 112 124 113 125 public static function htaccess_server_notice() { 114 if (!self::on_settings_page() ) {126 if (!self::on_settings_page() || !HtaccessManager::current_site_manages_file()) { 115 127 return; 116 128 } … … 147 159 */ 148 160 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 ) { 150 166 return; 151 167 } … … 175 191 // Direct links to a tab (?ycs_tab=<id>) only activate known schema 176 192 // 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)); 178 194 179 195 if (!in_array($active_tab, $known_tabs, true)) { … … 211 227 $first = true; 212 228 213 foreach ( Schema::sections() as $id => $section): ?>229 foreach (self::visible_sections() as $id => $section): ?> 214 230 <button 215 231 type="button" … … 248 264 $first = true; 249 265 250 foreach ( Schema::sections() as $id => $section): ?>266 foreach (self::visible_sections() as $id => $section): ?> 251 267 <div 252 268 id="panel-<?php echo esc_attr($id); ?>" … … 263 279 </div> 264 280 <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 ?> 266 295 </div> 267 296 </div> … … 295 324 } 296 325 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 297 377 private static function schema_tab_active($id, $active_tab, $first) { 298 378 // No tab selected (or an unknown/extra tab): the first schema tab wins. -
yuracode-security/trunk/src/Core/Firewall.php
r3696012 r3753699 127 127 'path' => 'firewall.protect_config', 128 128 '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.', 130 130 'yuracode-security', 131 131 ), -
yuracode-security/trunk/src/Firewall/HtaccessManager.php
r3714465 r3753699 5 5 use YuraCodeSecurity\Plugin; 6 6 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 8 10 class HtaccessManager { 9 11 const MARKER_START = '# BEGIN YuraCode Security'; … … 13 15 14 16 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 15 23 if (!self::should_apply($old, $new)) { 16 24 return; … … 43 51 44 52 if (!empty($firewall['protect_uploads'])) { 45 self:: write_uploads();53 self::apply_uploads_to_all_sites(true); 46 54 } else { 47 self:: remove_uploads();55 self::apply_uploads_to_all_sites(false); 48 56 } 49 57 } … … 132 140 133 141 public static function remove() { 142 if (!self::current_site_manages_file()) { 143 return; 144 } 145 134 146 $path = self::path(); 135 147 … … 152 164 } 153 165 154 self:: remove_uploads();166 self::apply_uploads_to_all_sites(false); 155 167 } 156 168 … … 177 189 } 178 190 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 179 221 public static function path() { 180 222 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(); 181 235 } 182 236 -
yuracode-security/trunk/src/Firewall/Rules8G.php
r3703514 r3753699 144 144 $out[] = '</Files>'; 145 145 $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)$">'; 147 154 $out[] = "\tRequire all denied"; 148 155 $out[] = '</FilesMatch>'; -
yuracode-security/trunk/src/Plugin.php
r3713443 r3753699 63 63 } 64 64 65 if (is_multisite()) { 66 add_action('wp_initialize_site', array(self::class, 'initialize_site'), 10, 2); 67 } 68 65 69 SettingsPage::init(); 66 70 … … 68 72 } 69 73 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(); 73 90 } 74 91 … … 76 93 } 77 94 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) { 79 103 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(); 81 152 } 82 153 -
yuracode-security/trunk/uninstall.php
r3696012 r3753699 9 9 \YuraCodeSecurity\Autoloader::register(); 10 10 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. 11 14 \YuraCodeSecurity\Firewall\HtaccessManager::remove(); 12 13 15 \YuraCodeSecurity\Firewall\HtaccessManager::cleanup_backups(); 14 15 delete_option( 'yuracode-security_settings' );16 16 17 17 global $wpdb; 18 18 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 23 foreach ( $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 4 4 * Plugin URI: https://security.yuraco.de/wordpress/ 5 5 * Description: Lightweight hardening, brute-force login protection, and an Apache .htaccess firewall (8G ruleset) for WordPress. 6 * Version: 1.0. 56 * Version: 1.0.6 7 7 * Author: YuraCode 8 8 * License: GPL-2.0-or-later … … 16 16 } 17 17 18 define( 'YURACODESECURITY_VERSION', '1.0. 5' );18 define( 'YURACODESECURITY_VERSION', '1.0.6' ); 19 19 define( 'YURACODESECURITY_FILE', __FILE__ ); 20 20 define( 'YURACODESECURITY_DIR', plugin_dir_path( __FILE__ ) );
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)