PluginProbe
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell / 3.13.3
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell v3.13.3
3.13.3 3.13.2 3.13.1 3.13.0 3.12.13 3.12.12 3.12.11 3.12.10 3.12.9 3.12.8 3.12.7 3.12.6 3.12.5 3.12.4 3.12.3 3.12.1 3.12.2 3.12.0 3.11.1 3.11.0 3.10.9 3.10.8 3.10.7 3.10.6 2.8.16 All 261 releases
← All changes | includes/core/logger/class-wpfnl-logger.php +174 -1 3.12.3 → 3.13.3 View file →
@@ -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 /**