PluginProbe ʕ •ᴥ•ʔ
WP 2FA – Two-factor authentication for WordPress / 2.4.2
WP 2FA – Two-factor authentication for WordPress v2.4.2
1.7.1 2.0.0 2.0.1 2.1.0 2.2.0 2.2.1 2.3.0 2.4.0 2.4.1 2.4.2 2.5.0 2.6.0 2.6.1 2.6.2 2.6.3 2.6.4 2.7.0 2.8.0 2.9.0 2.9.1 2.9.2 2.9.3 3.0.0 3.0.1 3.1.0 3.1.1 3.1.1.2 trunk 1.2.0 1.3.0 1.4.0 1.4.1 1.4.2 1.5.0 1.5.1 1.5.2 1.6.0 1.6.1 1.6.2 1.7.0
wp-2fa / includes / classes / Admin / Helpers / class-file-writer.php
wp-2fa / includes / classes / Admin / Helpers Last commit date
class-classes-helper.php 3 years ago class-file-writer.php 3 years ago class-php-helper.php 3 years ago class-user-helper.php 3 years ago class-wp-helper.php 3 years ago
class-file-writer.php
685 lines
1 <?php
2 /**
3 * Responsible for the User's operations
4 *
5 * @package wp2fa
6 * @subpackage helpers
7 * @since latest
8 * @copyright 2023 WP White Security
9 * @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
10 * @link https://wordpress.org/plugins/wp-2fa/
11 */
12
13 namespace WP2FA\Admin\Helpers;
14
15 defined( 'ABSPATH' ) || exit; // Exit if accessed directly.
16
17 /**
18 * User's settings class
19 */
20 if ( ! class_exists( '\WP2FA\Admin\Helpers\File_Writer' ) ) {
21
22 /**
23 * All the user related settings must go trough this class.
24 *
25 * @since 2.4.0
26 */
27 class File_Writer {
28
29 public const SECRET_NAME = 'WP2FA_ENCRYPT_KEY';
30
31 /**
32 * Saves a secret key in `wp-config.php`.
33 *
34 * @param string $secret The secret key to save.
35 *
36 * @return bool
37 *
38 * @since 2.4.0
39 */
40 public static function save_secret_key( string $secret ): bool {
41 if ( ! self::can_write_to_file( self::get_wp_config_file_path() ) ) {
42 return false;
43 }
44
45 $file = self::get_wp_config_file_path();
46 $contents = self::read( $file );
47
48 if ( false === $contents ) {
49 return false;
50 }
51
52 set_error_handler(
53 function ( $err_severity, $err_msg, $err_file, $err_line, array $err_context ) {
54 throw new \Error( $err_msg, 0, $err_severity, $err_file, $err_line );
55 },
56 E_WARNING
57 );
58
59 try {
60 $current_secret = constant( self::SECRET_NAME );
61 } catch ( \Error $e ) {
62 $current_secret = null;
63 }
64
65 restore_error_handler();
66
67 $matches_found = $current_secret ? substr_count( $contents, $current_secret ) : 0;
68
69 if ( ! $current_secret || ! $matches_found ) {
70 if ( substr_count( $contents, self::SECRET_NAME ) ) {
71
72 $line_ending = self::get_line_ending( $contents );
73
74 $contents = explode( $line_ending, $contents );
75
76 foreach ( $contents as $key => $line ) {
77 if ( stristr( $line, self::SECRET_NAME ) ) {
78 unset( $contents[ $key ] );
79 }
80 }
81
82 $contents = implode( $line_ending, array_values( $contents ) );
83 self::write( $file, $contents );
84 }
85 self::write_wp_config( '/** WP 2FA plugin data encryption key. For more information please visit wp2fa.io */' . "\n" . 'define( \'' . self::SECRET_NAME . '\', \'' . $secret . '\' );' );
86 return true;
87 }
88
89 if ( $matches_found > 1 ) {
90 return false;
91 }
92
93 $replaced = str_replace( $current_secret, $secret, $contents );
94
95 if ( ! $replaced ) {
96 return false;
97 }
98
99 $written = self::write( $file, $replaced );
100
101 if ( false === $written ) {
102 return false;
103 }
104
105 return true;
106 }
107
108 /**
109 * Gets the permissions of given directory
110 *
111 * @param string $dir - The name of the directory to check.
112 *
113 * @return bool|int
114 *
115 * @since 2.4.0
116 */
117 public static function get_permissions( string $dir ) {
118 if ( ! is_dir( $dir ) ) {
119 return false;
120 }
121
122 if ( ! PHP_Helper::is_callable( 'fileperms' ) ) {
123 return false;
124 }
125
126 $dir = rtrim( $dir, '/' );
127 // phpcs:ignore -- Have Tide ignore the following line. We use arguments that don't exist in early versions, but these versions ignore the arguments.
128 @clearstatcache( true, $dir );
129
130 return fileperms( $dir ) & 0777;
131 }
132
133 /**
134 * Writes a content to a given file
135 *
136 * @param string $file - The file to write to.
137 * @param string $contents - The contents of the file to write.
138 * @param boolean $append - Append the contents of the file or overwrite.
139 *
140 * @return mixed
141 *
142 * @since 2.4.0
143 */
144 public static function write( string $file, string $contents, $append = false ) {
145 $callable = array();
146
147 if ( PHP_Helper::is_callable( 'fopen' ) && PHP_Helper::is_callable( 'fwrite' ) && PHP_Helper::is_callable( 'flock' ) ) {
148 $callable[] = 'fopen';
149 }
150 if ( PHP_Helper::is_callable( 'file_put_contents' ) ) {
151 $callable[] = 'file_put_contents';
152 }
153
154 if ( empty( $callable ) ) {
155 return false;
156 }
157
158 if ( is_dir( $file ) ) {
159 return false;
160 }
161
162 if ( ! is_dir( dirname( $file ) ) ) {
163 $result = self::create_dir( dirname( $file ) );
164
165 if ( false === $result ) {
166 return false;
167 }
168 }
169
170 $file_existed = is_file( $file );
171 $success = false;
172
173 // Different permissions to try in case the starting set of permissions are prohibiting write.
174 $trial_perms = array(
175 false,
176 0644,
177 0664,
178 0666,
179 );
180
181 foreach ( $trial_perms as $perms ) {
182 if ( false !== $perms ) {
183 if ( ! isset( $original_file_perms ) ) {
184 $original_file_perms = self::get_permissions( $file );
185 }
186
187 self::chmod( $file, $perms );
188 }
189
190 if ( in_array( 'fopen', $callable, true ) ) {
191 if ( $append ) {
192 $mode = 'ab';
193 } else {
194 $mode = 'wb';
195 }
196
197 if ( false !== ( $fh = @fopen( $file, $mode ) ) ) { // phpcs:ignore -- Ignored the assignment on the same line
198 flock( $fh, LOCK_EX );
199
200 mbstring_binary_safe_encoding();
201
202 $data_length = strlen( $contents );
203 $bytes_written = @fwrite( $fh, $contents ); // phpcs:ignore -- Ignored the error silencing
204
205 reset_mbstring_encoding();
206
207 @flock( $fh, LOCK_UN ); // phpcs:ignore -- Ignored the error silencing
208 @fclose( $fh ); // phpcs:ignore -- Ignored the error silencing
209
210 if ( $data_length === $bytes_written ) {
211 $success = true;
212 }
213 }
214 }
215
216 if ( ! $success && in_array( 'file_put_contents', $callable, true ) ) {
217 if ( $append ) {
218 $flags = FILE_APPEND;
219 } else {
220 $flags = 0;
221 }
222
223 mbstring_binary_safe_encoding();
224
225 $data_length = strlen( $contents );
226 $bytes_written = @file_put_contents( $file, $contents, $flags ); // phpcs:ignore -- Ignored the silencing warning
227
228 reset_mbstring_encoding();
229
230 if ( $data_length === $bytes_written ) {
231 $success = true;
232 }
233 }
234
235 if ( $success ) {
236 if ( ! $file_existed ) {
237 // Set default file permissions for the new file.
238 self::chmod( $file, self::get_default_permissions() );
239 } elseif ( isset( $original_file_perms ) && ! is_wp_error( $original_file_perms ) ) {
240 // Reset the original file permissions if they were modified.
241 self::chmod( $file, $original_file_perms );
242 }
243
244 return true;
245 }
246
247 if ( ! $file_existed ) {
248 // If the file is new, there is no point attempting different permissions.
249 break;
250 }
251 }
252
253 return false;
254 }
255
256 /**
257 * Adds index.php and .htaccess files to the given directory
258 *
259 * @param string $dir - The directory to protect.
260 *
261 * @return bool
262 *
263 * @since 2.4.0
264 */
265 public static function add_file_listing_protection( string $dir ) {
266 $dir = rtrim( $dir, '/' );
267
268 if ( ! is_dir( $dir ) ) {
269 return false;
270 }
271
272 if ( self::exists( "$dir/index.php" ) ) {
273 return true;
274 }
275
276 return self::write( "$dir/index.php", "<?php\n// Silence is golden." );
277 }
278
279 /**
280 * Checks if given file exists
281 *
282 * @param string $file - The name of the file to check.
283 *
284 * @return bool
285 *
286 * @since 2.4.0
287 */
288 public static function exists( string $file ): bool {
289
290 @clearstatcache( true, $file ); // phpcs:ignore -- Have Tide ignore the following line. We use arguments that don't exist in early versions, but these versions ignore the arguments.
291
292 return @file_exists( $file ); // phpcs:ignore -- Have Tide ignore the following line. We use arguments that don't exist in early versions, but these versions ignore the arguments.
293 }
294
295 /**
296 * Check the setting that allows writing files.
297 *
298 * @param string $filename - The name of the file and path.
299 *
300 * @since 2.4.0
301 *
302 * @return bool True if files can be written to, false otherwise.
303 */
304 public static function can_write_to_file( string $filename ) {
305 return is_writable( $filename );
306 }
307
308 /**
309 * Get full file path to the site's wp-config.php file.
310 *
311 * @since 2.4.0
312 *
313 * @return string Full path to the wp-config.php file or a blank string if modifications for the file are disabled.
314 */
315 public static function get_wp_config_file_path() {
316 if ( file_exists( ABSPATH . 'wp-config.php' ) ) {
317 $path = ABSPATH . 'wp-config.php';
318 } else {
319 $path = '';
320 }
321
322 return $path;
323 }
324
325 /**
326 * Creates a directory structure
327 *
328 * @param string $dir - The directory to create.
329 *
330 * @return boolean
331 *
332 * @since 2.4.0
333 */
334 public static function create_dir( string $dir ): bool {
335 $dir = rtrim( $dir, '/' );
336
337 if ( is_dir( $dir ) ) {
338 self::add_file_listing_protection( $dir );
339
340 return true;
341 }
342
343 if ( self::exists( $dir ) ) {
344 return false;
345 }
346
347 if ( ! PHP_Helper::is_callable( 'mkdir' ) ) {
348 return false;
349 }
350
351 $parent = dirname( $dir );
352
353 while ( ! empty( $parent ) && ! is_dir( $parent ) && dirname( $parent ) !== $parent ) {
354 $parent = dirname( $parent );
355 }
356
357 if ( empty( $parent ) ) {
358 return false;
359 }
360
361 $perms = self::get_permissions( $parent );
362
363 if ( ! is_int( $perms ) ) {
364 $perms = self::get_default_permissions();
365 }
366
367 $cached_umask = umask( 0 );
368 $result = @mkdir( $dir, $perms, true ); // phpcs:ignore -- We don't want to have fatalities here.
369 umask( $cached_umask );
370
371 if ( $result ) {
372 self::add_file_listing_protection( $dir );
373
374 return true;
375 }
376
377 return false;
378 }
379
380 /**
381 * Gets the content of a file
382 *
383 * @param string $file - The name of the file.
384 *
385 * @return bool|string
386 *
387 * @since 2.4.0
388 */
389 protected static function get_file_contents( string $file ) {
390 if ( ! self::exists( $file ) ) {
391 return '';
392 }
393
394 $contents = self::read( $file );
395
396 if ( is_wp_error( $contents ) ) {
397 return false;
398 }
399
400 return $contents;
401 }
402
403 /**
404 * Write the supplied modification to the wp-config.php file.
405 *
406 * @since 2.4.0
407 *
408 * @param string $modification - The modification to add to the wp-config.php file.
409 *
410 * @return bool
411 */
412 private static function write_wp_config( $modification ) {
413 $file_path = self::get_wp_config_file_path();
414
415 return self::update( $file_path, $modification );
416 }
417
418 /**
419 * Updates the content of a file
420 *
421 * @param string $file - The name of the file to update.
422 * @param string $modification - The modification to be added to the file.
423 *
424 * @return boolean
425 *
426 * @since 2.4.0
427 */
428 private static function update( string $file, string $modification ): bool {
429 // Check to make sure that the settings give permission to write files.
430 if ( ! self::can_write_to_file( $file ) ) {
431
432 return false;
433 }
434
435 $contents = self::read( $file );
436
437 if ( is_wp_error( $contents ) ) {
438 return $contents;
439 }
440
441 if ( ! $contents ) {
442 return false;
443 }
444
445 $modification = ltrim( $modification, "\x0B\r\n\0" );
446 $modification = rtrim( $modification, " \t\x0B\r\n\0" );
447
448 if ( empty( $modification ) ) {
449 // If there isn't a new modification, write the content without any modification and return the result.
450
451 if ( empty( $contents ) ) {
452 $contents = PHP_EOL;
453 }
454
455 return false;
456 }
457
458 $placeholder = self::get_placeholder();
459
460 // Ensure that the generated placeholder can be uniquely identified in the contents.
461 while ( false !== strpos( $contents, $placeholder ) ) {
462 $placeholder = self::get_placeholder();
463 }
464
465 // Put the placeholder at the beginning of the file, after the <?php tag.
466 $contents = preg_replace( '/^(.*?<\?(?:php)?)\s*(?:\r\r\n|\r\n|\r|\n)/', "\${1}$placeholder", $contents, 1 );
467
468 if ( false === strpos( $contents, $placeholder ) ) {
469 $contents = preg_replace( '/^(.*?<\?(?:php)?)\s*(.+(?:\r\r\n|\r\n|\r|\n))/', "\${1}$placeholder$2", $contents, 1 );
470 }
471
472 if ( false === strpos( $contents, $placeholder ) ) {
473 $contents = "<?php$placeholder?" . ">$contents";
474 }
475
476 // Pad away from existing sections when adding iThemes Security modifications.
477 $line_ending = self::get_line_ending( $contents );
478
479 while ( ! preg_match( "/(?:^|(?:(?<!\r)\n|\r(?!\n)|(?<!\r)\r\n|\r\r\n)(?:(?<!\r)\n|\r(?!\n)|(?<!\r)\r\n|\r\r\n))$placeholder/", $contents ) ) {
480 $contents = preg_replace( "/$placeholder/", "$line_ending$placeholder", $contents );
481 }
482 while ( ! preg_match( "/$placeholder(?:$|(?:(?<!\r)\n|\r(?!\n)|(?<!\r)\r\n|\r\r\n)(?:(?<!\r)\n|\r(?!\n)|(?<!\r)\r\n|\r\r\n))/", $contents ) ) {
483 $contents = preg_replace( "/$placeholder/", "$placeholder$line_ending", $contents );
484 }
485
486 // Ensure that the file ends in a newline if the placeholder is at the end.
487 $contents = preg_replace( "/$placeholder$/", "$placeholder$line_ending", $contents );
488
489 if ( ! empty( $modification ) ) {
490 // Normalize line endings of the modification to match the file's line endings.
491 $modification = self::normalize_line_endings( $modification, $line_ending );
492
493 // Exchange the placeholder with the modification.
494 $contents = preg_replace( "/$placeholder/", $modification, $contents );
495 }
496
497 // Write the new contents to the file and return the results.
498 return self::write( $file, $contents );
499 }
500
501 /**
502 * Generates unique placeholder to be used in the string
503 *
504 * @return string
505 *
506 * @since 2.4.0
507 */
508 private static function get_placeholder(): string {
509 $characters = str_split( 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' );
510
511 $string = '';
512
513 for ( $x = 0; $x < 100; $x++ ) {
514 $string .= array_rand( $characters );
515 }
516
517 return $string;
518 }
519
520 /**
521 * Returns to proper line endings of a given content
522 *
523 * @param string $contents - The text to be checked.
524 *
525 * @return string
526 *
527 * @since 2.4.0
528 */
529 private static function get_line_ending( string $contents ) {
530 if ( empty( $contents ) ) {
531 return PHP_EOL;
532 }
533
534 $count["\n"] = preg_match_all( "/(?<!\r)\n/", $contents, $matches );
535 $count["\r"] = preg_match_all( "/\r(?!\n)/", $contents, $matches );
536 $count["\r\n"] = preg_match_all( "/(?<!\r)\r\n/", $contents, $matches );
537 $count["\r\r\n"] = preg_match_all( "/\r\r\n/", $contents, $matches );
538
539 if ( 0 === array_sum( $count ) ) {
540 return PHP_EOL;
541 }
542
543 $maxes = array_keys( $count, max( $count ), true );
544
545 if ( in_array( "\r\r\n", $maxes, true ) ) {
546 return "\r\r\n";
547 }
548
549 return $maxes[0];
550 }
551
552 /**
553 * Normalizing fileendings for different platforms
554 *
555 * @param string $content - The file content to be checked.
556 * @param string $line_ending - Line endings to be used.
557 *
558 * @return string
559 *
560 * @since 2.4.0
561 */
562 private static function normalize_line_endings( string $content, string $line_ending = "\n" ): string {
563 return preg_replace( '/(?<!\r)\n|\r(?!\n)|(?<!\r)\r\n|\r\r\n/', $line_ending, $content );
564 }
565
566 /**
567 * Reads the content of a file
568 *
569 * @param string $file - The file to read.
570 *
571 * @return bool|string
572 *
573 * @since 2.4.0
574 */
575 private static function read( string $file ) {
576 if ( ! is_file( $file ) ) {
577 return false;
578 }
579
580 $callable = array();
581
582 if ( PHP_Helper::is_callable( 'file_get_contents' ) ) {
583 $callable[] = 'file_get_contents';
584 }
585 if ( PHP_Helper::is_callable( 'fopen' ) && PHP_Helper::is_callable( 'feof' ) && PHP_Helper::is_callable( 'fread' ) && PHP_Helper::is_callable( 'flock' ) ) {
586 $callable[] = 'fopen';
587 }
588
589 if ( empty( $callable ) ) {
590 return false;
591 }
592
593 $contents = false;
594
595 // Different permissions to try in case the starting set of permissions are prohibiting read.
596 $trial_perms = array(
597 false,
598 0644,
599 0664,
600 0666,
601 );
602
603 foreach ( $trial_perms as $perms ) {
604 if ( false !== $perms ) {
605 if ( ! isset( $original_file_perms ) ) {
606 $original_file_perms = self::get_permissions( $file );
607 }
608
609 self::chmod( $file, $perms );
610 }
611
612 if ( in_array( 'fopen', $callable, true ) ) {
613 if ( false !== ( $fh = fopen( $file, 'rb' ) ) ) { // phpcs:ignore -- Ignored the assigned on the same line error
614 flock( $fh, LOCK_SH );
615
616 $contents = '';
617
618 while ( ! feof( $fh ) ) {
619 $contents .= fread( $fh, 1024 ); // phpcs:ignore -- Ignored the file operation notification
620 }
621
622 flock( $fh, LOCK_UN );
623 fclose( $fh ); // phpcs:ignore -- Ignored the file operation notification
624 }
625 }
626
627 if ( ( false === $contents ) && in_array( 'file_get_contents', $callable, true ) ) {
628 $contents = file_get_contents( $file ); // phpcs:ignore -- Ignored the wp_remote_get usage
629 }
630
631 if ( false !== $contents ) {
632 if ( isset( $original_file_perms ) && is_int( $original_file_perms ) ) {
633 // Reset the original file permissions if they were modified.
634 self::chmod( $file, $original_file_perms );
635 }
636
637 return $contents;
638 }
639 }
640
641 return false;
642 }
643
644 /**
645 * Changes the permissions of a file
646 *
647 * @param string $file - The file to change permissions to.
648 * @param mixed $perms - The permissions to be set.
649 *
650 * @return bool
651 *
652 * @since 2.4.0
653 */
654 private static function chmod( string $file, $perms ): bool {
655 if ( ! is_int( $perms ) ) {
656 return \CURLOPT_SSL_FALSESTART;
657 }
658
659 if ( ! PHP_Helper::is_callable( 'chmod' ) ) {
660 return false;
661 }
662
663 return @chmod( $file, $perms ); // phpcs:ignore -- Don't need fatalities here.
664 }
665
666 /**
667 * Returns the default filesystem permissions
668 *
669 * @return integer
670 *
671 * @since 2.4.0
672 */
673 private static function get_default_permissions() {
674
675 $perms = self::get_permissions( ABSPATH );
676
677 if ( ! is_wp_error( $perms ) ) {
678 return $perms;
679 }
680
681 return 0755;
682 }
683 }
684 }
685