| @@ -9,10 +9,25 @@ | ||
| 9 | 9 | class Wpfnl_Logger { |
| 10 | 10 | |
| 11 | 11 | use SingletonTrait; |
| 12 | 12 | |
| 13 | + /** | |
| 14 | + * Bumped whenever the directory hardening below changes, so an install | |
| 15 | + * hardened by an earlier release re-runs it once on upgrade. | |
| 16 | + * | |
| 17 | + * @since 3.12.14 | |
| 18 | + */ | |
| 19 | + const PROTECTION_VERSION = '2'; | |
| 13 | 20 | |
| 21 | + /** | |
| 22 | + * Option holding the hardening version already applied. | |
| 23 | + * | |
| 24 | + * @since 3.12.14 | |
| 25 | + */ | |
| 26 | + const PROTECTION_OPTION = '_wpfnl_log_protection_version'; | |
| 14 | 27 | |
| 28 | + | |
| 29 | + | |
| 15 | 30 | /** |
| 16 | 31 | * Log location, URL path. |
| 17 | 32 | * |
| 18 | 33 | * @since 2.5.9 |
| @@ -79,12 +94,152 @@ | ||
| 79 | 94 | * @since 2.5.9 |
| 80 | 95 | */ |
| 81 | 96 | public function create_log_folder() { |
| 82 | 97 | wp_mkdir_p( $this->log_location_dir ); |
| 98 | + $this->protect_log_folder(); | |
| 83 | 99 | } |
| 84 | 100 | |
| 85 | 101 | |
| 86 | 102 | /** |
| 103 | + * Harden the log directory. | |
| 104 | + * | |
| 105 | + * Server config files cannot carry this on their own: nginx never reads | |
| 106 | + * .htaccess, and neither does Apache with AllowOverride None, so on those | |
| 107 | + * hosts a log file under uploads stays fetchable no matter what is written | |
| 108 | + * beside it. The unguessable file name suffix is therefore the defence that | |
| 109 | + * holds everywhere, and the rules written here are the second layer for | |
| 110 | + * servers that do honour them. | |
| 111 | + * | |
| 112 | + * The legacy directory used up to 3.0.5 is hardened too - installs that | |
| 113 | + * upgraded through that release still have readable logs sitting there. | |
| 114 | + * | |
| 115 | + * @since 3.12.8 | |
| 116 | + */ | |
| 117 | + protected function protect_log_folder() { | |
| 118 | + // Runs on every page load, so the settled state must cost nothing beyond | |
| 119 | + // one autoloaded option read. | |
| 120 | + if ( get_option( self::PROTECTION_OPTION ) === self::PROTECTION_VERSION ) { | |
| 121 | + return; | |
| 122 | + } | |
| 123 | + | |
| 124 | + $uploads = wp_upload_dir( null, false ); | |
| 125 | + $legacy = ! empty( $uploads['basedir'] ) ? $uploads['basedir'] . '/wpfunnels-logs' : ''; | |
| 126 | + | |
| 127 | + foreach ( array( $this->log_location_dir, $legacy ) as $dir ) { | |
| 128 | + if ( ! $dir || ! is_dir( $dir ) || ! is_writable( $dir ) ) { | |
| 129 | + continue; | |
| 130 | + } | |
| 131 | + | |
| 132 | + $this->write_protection_files( $dir, true ); | |
| 133 | + $this->rename_predictable_logs( $dir ); | |
| 134 | + } | |
| 135 | + | |
| 136 | + update_option( self::PROTECTION_OPTION, self::PROTECTION_VERSION ); | |
| 137 | + } | |
| 138 | + | |
| 139 | + | |
| 140 | + /** | |
| 141 | + * Write the deny rules and placeholders into a log directory. | |
| 142 | + * | |
| 143 | + * @param String $dir Directory to protect. | |
| 144 | + * @param Bool $refresh Rewrite files that already exist. | |
| 145 | + * | |
| 146 | + * @since 3.12.14 | |
| 147 | + */ | |
| 148 | + protected function write_protection_files( $dir, $refresh = false ) { | |
| 149 | + $dir = trailingslashit( $dir ); | |
| 150 | + | |
| 151 | + // Order/Deny is Apache 2.2 syntax. On a 2.4 build without | |
| 152 | + // mod_access_compat it is an unknown directive, which fails the whole | |
| 153 | + // directory with a 500 instead of denying anything - hence the guards. | |
| 154 | + $htaccess = $dir . '.htaccess'; | |
| 155 | + if ( $refresh || ! file_exists( $htaccess ) ) { | |
| 156 | + $rules = "# WPFunnels log files. Deny direct web access.\n"; | |
| 157 | + $rules .= "<IfModule mod_authz_core.c>\n"; | |
| 158 | + $rules .= "\tRequire all denied\n"; | |
| 159 | + $rules .= "</IfModule>\n"; | |
| 160 | + $rules .= "<IfModule !mod_authz_core.c>\n"; | |
| 161 | + $rules .= "\tOrder deny,allow\n"; | |
| 162 | + $rules .= "\tDeny from all\n"; | |
| 163 | + $rules .= "</IfModule>\n"; | |
| 164 | + $rules .= "Options -Indexes\n"; | |
| 165 | + | |
| 166 | + @file_put_contents( $htaccess, $rules ); // @codingStandardsIgnoreLine. | |
| 167 | + } | |
| 168 | + | |
| 169 | + $index = $dir . 'index.php'; | |
| 170 | + if ( ! file_exists( $index ) ) { | |
| 171 | + @file_put_contents( $index, "<?php\n// Silence is golden.\n" ); // @codingStandardsIgnoreLine. | |
| 172 | + } | |
| 173 | + | |
| 174 | + $web_config = $dir . 'web.config'; | |
| 175 | + if ( $refresh || ! file_exists( $web_config ) ) { | |
| 176 | + $config = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"; | |
| 177 | + $config .= "<configuration>\n"; | |
| 178 | + $config .= "\t<system.webServer>\n"; | |
| 179 | + $config .= "\t\t<security>\n"; | |
| 180 | + $config .= "\t\t\t<requestFiltering>\n"; | |
| 181 | + $config .= "\t\t\t\t<fileExtensions>\n"; | |
| 182 | + $config .= "\t\t\t\t\t<add fileExtension=\".log\" allowed=\"false\" />\n"; | |
| 183 | + $config .= "\t\t\t\t</fileExtensions>\n"; | |
| 184 | + $config .= "\t\t\t</requestFiltering>\n"; | |
| 185 | + $config .= "\t\t</security>\n"; | |
| 186 | + $config .= "\t</system.webServer>\n"; | |
| 187 | + $config .= "</configuration>\n"; | |
| 188 | + | |
| 189 | + @file_put_contents( $web_config, $config ); // @codingStandardsIgnoreLine. | |
| 190 | + } | |
| 191 | + } | |
| 192 | + | |
| 193 | + | |
| 194 | + /** | |
| 195 | + * Rename log files still carrying a guessable name. | |
| 196 | + * | |
| 197 | + * Files written before 3.12.14 are named from a fixed prefix and the date | |
| 198 | + * alone, so hardening new writes is not enough - the existing files stay | |
| 199 | + * fetchable. Renaming keeps the contents and closes the exposure. | |
| 200 | + * | |
| 201 | + * @param String $dir Directory to migrate. | |
| 202 | + * | |
| 203 | + * @since 3.12.14 | |
| 204 | + */ | |
| 205 | + protected function rename_predictable_logs( $dir ) { | |
| 206 | + $suffix = self::get_log_file_suffix(); | |
| 207 | + $files = glob( trailingslashit( $dir ) . '*.log' ); | |
| 208 | + | |
| 209 | + if ( ! is_array( $files ) ) { | |
| 210 | + return; | |
| 211 | + } | |
| 212 | + | |
| 213 | + foreach ( $files as $file ) { | |
| 214 | + if ( false !== strpos( basename( $file ), $suffix ) ) { | |
| 215 | + continue; | |
| 216 | + } | |
| 217 | + | |
| 218 | + $target = substr( $file, 0, -4 ) . '-' . $suffix . '.log'; | |
| 219 | + if ( ! file_exists( $target ) ) { | |
| 220 | + @rename( $file, $target ); // @codingStandardsIgnoreLine. | |
| 221 | + } | |
| 222 | + } | |
| 223 | + } | |
| 224 | + | |
| 225 | + | |
| 226 | + /** | |
| 227 | + * Site-specific suffix for log file names. | |
| 228 | + * | |
| 229 | + * Derived from the site salts, so it is stable for a site and unguessable | |
| 230 | + * from outside it. This is what keeps the logs private on servers that | |
| 231 | + * ignore .htaccess. | |
| 232 | + * | |
| 233 | + * @return String | |
| 234 | + * @since 3.12.14 | |
| 235 | + */ | |
| 236 | + public static function get_log_file_suffix() { | |
| 237 | + return wp_hash( 'wpfnl-log-file-name' ); | |
| 238 | + } | |
| 239 | + | |
| 240 | + | |
| 241 | + /** | |
| 87 | 242 | * Create log file. |
| 88 | 243 | * |
| 89 | 244 | * @param String $log_location_dir |
| 90 | 245 | * @param String $file_name |
| @@ -141,9 +296,9 @@ | ||
| 141 | 296 | public static function modify_log_file( $log_type, $content, $header_text = '' ){ |
| 142 | 297 | |
| 143 | 298 | if( $log_type && $content && defined( 'WPFNL_LOG_FILE' ) && defined( 'WPFNL_LOG_FILE_DIR' ) ){ |
| 144 | 299 | |
| 145 | - $file_name = WPFNL_LOG_FILE.'-'.trim(strtolower($log_type)).'-log-'.date('Y-m-d').'.log'; | |
| 300 | + $file_name = WPFNL_LOG_FILE.'-'.trim(strtolower($log_type)).'-log-'.date('Y-m-d').'-'.self::get_log_file_suffix().'.log'; | |
| 146 | 301 | $time = new \DateTimeImmutable('now', wp_timezone()); |
| 147 | 302 | $current_time = $time->format("h:i A"); |
| 148 | 303 | $header_content = "\nWPFunnels - ".$current_time." - ".$header_text."\n"; |
| 149 | 304 | |
| @@ -154,12 +309,30 @@ | ||
| 154 | 309 | if ( ! is_writable( $file_name ) ) { |
| 155 | 310 | return; |
| 156 | 311 | } |
| 157 | 312 | |
| 313 | + // Neutralise any PHP open tags in attacker-controlled content so the | |
| 314 | + // log file can never be executed if it is ever included by PHP. | |
| 315 | + $header_content = self::sanitize_log_content( $header_content ); | |
| 316 | + $content = is_scalar( $content ) ? self::sanitize_log_content( (string) $content ) : ''; | |
| 317 | + | |
| 158 | 318 | file_put_contents ($file_name, $header_content, FILE_APPEND ); |
| 159 | 319 | file_put_contents ($file_name, $content, FILE_APPEND ); |
| 160 | 320 | |
| 161 | 321 | } |
| 322 | + } | |
| 323 | + | |
| 324 | + | |
| 325 | + /** | |
| 326 | + * Strip PHP open tags from log content to prevent code execution. | |
| 327 | + * | |
| 328 | + * @param String $content | |
| 329 | + * | |
| 330 | + * @return String | |
| 331 | + * @since 3.12.8 | |
| 332 | + */ | |
| 333 | + protected static function sanitize_log_content( $content ) { | |
| 334 | + return str_ireplace( array( '<?php', '<?=', '<?' ), '', $content ); | |
| 162 | 335 | } |
| 163 | 336 | |
| 164 | 337 | |
| 165 | 338 | /** |