PluginProbe ʕ •ᴥ•ʔ
WP 2FA – Two-factor authentication for WordPress / 4.0.0
WP 2FA – Two-factor authentication for WordPress v4.0.0
4.0.0 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-ajax-helper.php 22 hours ago class-classes-helper.php 22 hours ago class-email-templates.php 22 hours ago class-file-writer.php 22 hours ago class-methods-helper.php 22 hours ago class-php-helper.php 22 hours ago class-sms-templates.php 22 hours ago class-user-helper.php 22 hours ago class-wp-helper.php 22 hours ago index.php 22 hours ago
class-file-writer.php
1135 lines
1 <?php
2 /**
3 * Responsible for the File writing operations
4 *
5 * @package wp2fa
6 * @subpackage helpers
7 * @since 2.4.0
8 * @copyright 2026 Melapress
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 * File writer settings class
19 */
20 if ( ! class_exists( '\WP2FA\Admin\Helpers\File_Writer' ) ) {
21
22 /**
23 * All the file operations 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 public const WP2FA_UPLOADS_DIR = 'wp-2fa-data';
32
33 /**
34 * Saves a secret key in `wp-config.php`.
35 *
36 * @param string $secret The secret key to save.
37 *
38 * @return bool
39 *
40 * @since 2.4.0
41 */
42 public static function save_secret_key( string $secret ): bool {
43 if ( ! self::can_write_to_file( self::get_wp_config_file_path() ) ) {
44 return false;
45 }
46
47 $file = self::get_wp_config_file_path();
48 $contents = self::read( $file );
49
50 if ( false === $contents ) {
51 return false;
52 }
53
54 $secret_literal = self::build_secret_literal( $secret );
55
56 if ( '' === $secret_literal ) {
57 return false;
58 }
59
60 $definition = "define( '" . self::SECRET_NAME . "', " . $secret_literal . ' );';
61 $definition_list = preg_match_all( self::get_secret_definition_pattern(), $contents );
62
63 if ( false === $definition_list ) {
64 return false;
65 }
66
67 if ( 0 === $definition_list ) {
68 if ( substr_count( $contents, self::SECRET_NAME ) ) {
69
70 $line_ending = self::get_line_ending( $contents );
71
72 $contents = explode( $line_ending, $contents );
73
74 foreach ( $contents as $key => $line ) {
75 if ( stristr( $line, '/** WP 2FA plugin data encryption key. ' ) ) {
76 unset( $contents[ $key ] );
77
78 continue;
79 }
80 if ( stristr( $line, self::SECRET_NAME ) ) {
81 unset( $contents[ $key ] );
82
83 continue;
84 }
85 }
86
87 $contents = implode( $line_ending, array_values( $contents ) );
88 self::write( $file, $contents );
89 }
90 self::write_wp_config( '/** WP 2FA plugin data encryption key. For more information please visit melapress.com */' . "\n" . $definition );
91 return true;
92 }
93
94 if ( $definition_list > 1 ) {
95 return false;
96 }
97
98 $replaced = self::replace_secret_definition( $contents, $definition );
99
100 if ( false === $replaced ) {
101 return false;
102 }
103
104 $written = self::write( $file, $replaced );
105
106 if ( false === $written ) {
107 return false;
108 }
109
110 return true;
111 }
112
113 /**
114 * Gets the permissions of given directory
115 *
116 * @param string $dir - The name of the directory to check.
117 *
118 * @return bool|int
119 *
120 * @since 2.4.0
121 */
122 public static function get_permissions( string $dir ) {
123 if ( ! is_dir( $dir ) ) {
124 return false;
125 }
126
127 if ( ! PHP_Helper::is_callable( 'fileperms' ) ) {
128 return false;
129 }
130
131 $dir = rtrim( $dir, '/' );
132
133 @clearstatcache( true, $dir ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
134
135 return fileperms( $dir ) & 0777;
136 }
137
138 /**
139 * Writes a content to a given file
140 *
141 * @param string $file - The file to write to.
142 * @param string $contents - The contents of the file to write.
143 * @param boolean $append - Append the contents of the file or overwrite.
144 *
145 * @return mixed
146 *
147 * @since 2.4.0
148 */
149 public static function write( string $file, string $contents, $append = false ) {
150 $callable = array();
151
152 if ( PHP_Helper::is_callable( 'fopen' ) && PHP_Helper::is_callable( 'fwrite' ) && PHP_Helper::is_callable( 'flock' ) ) {
153 $callable[] = 'fopen';
154 }
155 if ( PHP_Helper::is_callable( 'file_put_contents' ) ) {
156 $callable[] = 'file_put_contents';
157 }
158
159 if ( empty( $callable ) ) {
160 return false;
161 }
162
163 if ( ! self::is_path_allowed( $file ) ) {
164 return false;
165 }
166
167 if ( is_dir( $file ) ) {
168 return false;
169 }
170
171 if ( ! is_dir( dirname( $file ) ) ) {
172 $result = self::create_dir( dirname( $file ) );
173
174 if ( false === $result ) {
175 return false;
176 }
177 }
178
179 $file_existed = is_file( $file );
180 $success = false;
181
182 // Different permissions to try in case the starting set of permissions are prohibiting write.
183 $trial_perms = array(
184 false,
185 0644,
186 0664,
187 0666,
188 );
189
190 foreach ( $trial_perms as $perms ) {
191 if ( false !== $perms ) {
192 if ( ! isset( $original_file_perms ) ) {
193 $original_file_perms = self::get_permissions( $file );
194 }
195
196 self::chmod( $file, $perms );
197 }
198
199 if ( in_array( 'fopen', $callable, true ) ) {
200 if ( $append ) {
201 $mode = 'ab';
202 } else {
203 $mode = 'wb';
204 }
205
206 // Assignment inside condition is intentional to attempt the
207 // fopen and capture the resource in one step; ignore the PHPCS
208 // assignment-in-condition sniff.
209 if ( false !== ( $fh = @fopen( $file, $mode ) ) ) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.Found, Squiz.PHP.DisallowMultipleAssignments.FoundInControlStructure, Squiz.PHP.DisallowMultipleAssignments.FoundInControlStructure, WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_fopen
210 flock( $fh, LOCK_EX );
211
212 mbstring_binary_safe_encoding();
213
214 $data_length = strlen( $contents );
215 // Error suppression is deliberate: we attempt several IO
216 // strategies and handle failures without raising warnings.
217 $bytes_written = @fwrite( $fh, $contents ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
218
219 reset_mbstring_encoding();
220
221 // Silencing these cleanup calls to avoid noise if resources
222 // are invalid or already closed in older environments.
223 @flock( $fh, LOCK_UN ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
224 @fclose( $fh ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_fclose
225
226 if ( $data_length === $bytes_written ) {
227 $success = true;
228 }
229 }
230 }
231
232 if ( ! $success && in_array( 'file_put_contents', $callable, true ) ) {
233 if ( $append ) {
234 $flags = FILE_APPEND;
235 } else {
236 $flags = 0;
237 }
238
239 mbstring_binary_safe_encoding();
240
241 $data_length = strlen( $contents );
242 // Use file_put_contents when available; suppress warnings and
243 // detect success via return value instead of raising errors.
244 $bytes_written = @file_put_contents( $file, $contents, $flags ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
245
246 reset_mbstring_encoding();
247
248 if ( $data_length === $bytes_written ) {
249 $success = true;
250 }
251 }
252
253 if ( $success ) {
254 if ( ! $file_existed ) {
255 // Set default file permissions for the new file.
256 self::chmod( $file, self::get_default_permissions() );
257 } elseif ( isset( $original_file_perms ) && ! is_wp_error( $original_file_perms ) ) {
258 // Reset the original file permissions if they were modified.
259 self::chmod( $file, $original_file_perms );
260 }
261
262 // clearstatcache may be silenced on some hosts; ignore the PHPCS
263 // NoSilencedErrors warning for this cleanup call.
264 @clearstatcache( true, $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
265
266 return true;
267 }
268
269 if ( ! $file_existed ) {
270 // If the file is new, there is no point attempting different permissions.
271 break;
272 }
273 }
274
275 return false;
276 }
277
278 /**
279 * Adds index.php and .htaccess files to the given directory
280 *
281 * @param string $dir - The directory to protect.
282 *
283 * @return bool
284 *
285 * @since 2.4.0
286 */
287 public static function add_file_listing_protection( string $dir ) {
288 $dir = rtrim( $dir, \DIRECTORY_SEPARATOR );
289
290 if ( ! is_dir( $dir ) ) {
291 return false;
292 }
293
294 if ( ! self::is_path_allowed( $dir ) ) {
295 return false;
296 }
297
298 if ( self::exists( $dir . \DIRECTORY_SEPARATOR . 'index.php' ) ) {
299 return true;
300 }
301
302 return self::write( $dir . \DIRECTORY_SEPARATOR . '.htaccess', 'Deny from all' ) &&
303 self::write( $dir . \DIRECTORY_SEPARATOR . 'index.php', "<?php\n// Silence is golden." );
304 }
305
306 /**
307 * Checks if given file exists
308 *
309 * @param string $file - The name of the file to check.
310 *
311 * @return bool
312 *
313 * @since 2.4.0
314 */
315 public static function exists( string $file ): bool {
316 if ( ! self::is_path_allowed( $file ) ) {
317 return false;
318 }
319
320 @clearstatcache( true, $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
321
322 // Use error suppression to avoid warnings on inaccessible paths; this
323 // helper handles the error conditions, so ignore the PHPCS rule.
324 return @file_exists( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
325 }
326
327 /**
328 * Check the setting that allows writing files.
329 *
330 * @param string $filename - The name of the file and path.
331 *
332 * @since 2.4.0
333 *
334 * @return bool True if files can be written to, false otherwise.
335 */
336 public static function can_write_to_file( string $filename ) {
337 if ( ! self::is_path_allowed( $filename ) ) {
338 return false;
339 }
340
341 return is_writable( $filename ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable
342 }
343
344 /**
345 * Get full file path to the site's wp-config.php file.
346 *
347 * @since 2.4.0
348 *
349 * @return string Full path to the wp-config.php file or a blank string if modifications for the file are disabled.
350 */
351 public static function get_wp_config_file_path() {
352
353 if ( file_exists( ABSPATH . 'wp-config.php' ) ) {
354
355 /** The config file resides in ABSPATH */
356 $path = ABSPATH . 'wp-config.php';
357
358 } elseif ( @file_exists( dirname( ABSPATH ) . '/wp-config.php' ) && ! @file_exists( dirname( ABSPATH ) . '/wp-settings.php' ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
359
360 /** The config file resides one level above ABSPATH */
361 $path = dirname( ABSPATH ) . '/wp-config.php';
362
363 } else {
364 $path = '';
365 }
366
367 /**
368 * Gives the ability to manually change the path to the config file.
369 *
370 * @param string - The current value for WP config file path.
371 *
372 * @since 2.6.2
373 */
374 $path = \apply_filters( WP_2FA_PREFIX . 'config_file_path', (string) $path );
375
376 return $path;
377 }
378
379 /**
380 * Creates a directory structure
381 *
382 * @param string $dir - The directory to create.
383 *
384 * @return boolean
385 *
386 * @since 2.4.0
387 */
388 public static function create_dir( string $dir ): bool {
389 $dir = rtrim( $dir, '/' );
390
391 if ( ! self::is_path_allowed( $dir ) ) {
392 return false;
393 }
394
395 if ( is_dir( $dir ) ) {
396 self::add_file_listing_protection( $dir );
397
398 return true;
399 }
400
401 if ( self::exists( $dir ) ) {
402 return false;
403 }
404
405 if ( ! PHP_Helper::is_callable( 'mkdir' ) ) {
406 return false;
407 }
408
409 $parent = dirname( $dir );
410
411 while ( ! empty( $parent ) && ! is_dir( $parent ) && dirname( $parent ) !== $parent ) {
412 $parent = dirname( $parent );
413 }
414
415 if ( empty( $parent ) ) {
416 return false;
417 }
418
419 $perms = self::get_permissions( $parent );
420
421 if ( ! is_int( $perms ) ) {
422 $perms = self::get_default_permissions();
423 }
424
425 $cached_umask = umask( 0 );
426 // Attempt mkdir but suppress warnings on failure — we handle return
427 // values and do not want noisy warnings on restricted hosts.
428 $result = @mkdir( $dir, $perms, true ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_mkdir
429 umask( $cached_umask );
430
431 if ( $result ) {
432 self::add_file_listing_protection( $dir );
433
434 return true;
435 }
436
437 return false;
438 }
439
440 /**
441 * Retrieves the full path to plugin's working directory. Returns a folder path with a trailing slash. It also
442 * creates the folder unless the $skip_creation parameter is set to true.
443 *
444 * Default path is "{uploads folder}/self::WP2FA_UPLOADS_DIR/"
445 *
446 * @param string $path Optional path relative to the working directory.
447 * @param bool $skip_creation If true, the folder will not be created.
448 * @param bool $ignore_site If true, there will be no sub-site specific subfolder in multisite context.
449 *
450 * @return string|\WP_Error
451 *
452 * @since 2.6.0
453 */
454 public static function get_upload_path( $path = '', $skip_creation = false, $ignore_site = false ) {
455 $result = '';
456
457 $upload_dir = wp_upload_dir( null, false );
458 if ( is_array( $upload_dir ) && array_key_exists( 'basedir', $upload_dir ) ) {
459 $result = $upload_dir['basedir'] . \DIRECTORY_SEPARATOR . self::WP2FA_UPLOADS_DIR . \DIRECTORY_SEPARATOR;
460 } elseif ( defined( 'WP_CONTENT_DIR' ) ) {
461 // Fallback in case there is a problem with filesystem.
462 $result = WP_CONTENT_DIR . \DIRECTORY_SEPARATOR . 'uploads' . \DIRECTORY_SEPARATOR . self::WP2FA_UPLOADS_DIR . \DIRECTORY_SEPARATOR;
463 }
464
465 if ( empty( $result ) ) {
466 // Empty result here means invalid custom path or a problem with WordPress (uploads folder issue or mission WP_CONTENT_DIR).
467 return new \WP_Error( 'wp-2fa_uplaods_dir_missing', __( 'The base of WSAL working directory cannot be determined. Custom path is invalid or there is some other issue with your WordPress installation.', 'wp-2fa' ) );
468 }
469
470 // Append site specific subfolder in multisite context.
471 if ( ! $ignore_site && WP_Helper::is_multisite() ) {
472 $site_id = \get_current_blog_id();
473 if ( $site_id > 0 ) {
474 $result .= 'sites' . \DIRECTORY_SEPARATOR . $site_id . \DIRECTORY_SEPARATOR;
475 }
476 }
477
478 // Append optional path passed as a parameter.
479 if ( is_string( $path ) && '' !== $path ) {
480 $sanitized_subpath = self::sanitize_relative_subpath( $path );
481
482 if ( null === $sanitized_subpath ) {
483 return new \WP_Error(
484 'invalid_subdirectory',
485 __( 'The requested WP 2FA storage subdirectory is invalid.', 'wp-2fa' )
486 );
487 }
488
489 if ( '' !== $sanitized_subpath ) {
490 $result .= $sanitized_subpath . \DIRECTORY_SEPARATOR;
491 }
492 }
493
494 if ( ! file_exists( $result ) ) {
495 if ( ! $skip_creation ) {
496 if ( ! \wp_mkdir_p( $result ) ) {
497 return new \WP_Error(
498 'mkdir_failed',
499 sprintf(
500 /* translators: %s: Directory path. */
501 __( 'Unable to create directory %s. Is its parent directory writable by the server?', 'wp-2fa' ),
502 esc_html( $result )
503 )
504 );
505 }
506 }
507
508 self::add_file_listing_protection( $result );
509 }
510
511 return $result;
512 }
513
514 /**
515 * Remove the supplied file.
516 *
517 * @param string $file - The name of the file and path.
518 *
519 * @return bool|WP_Error Boolean true on success or a WP_Error object if an error occurs.
520 *
521 * @since 2.6.0
522 */
523 public static function remove( $file ) {
524 if ( ! self::is_path_allowed( $file ) ) {
525 return new \WP_Error(
526 'wp-2fa',
527 __( 'Removing files outside the WP 2FA data directory is not permitted.', 'wp-2fa' )
528 );
529 }
530
531 if ( ! self::exists( $file ) ) {
532 return true;
533 }
534
535 if ( ! PHP_Helper::is_callable( 'unlink' ) ) {
536 return new \WP_Error(
537 'wp-2fa',
538 // translators: the name of the file.
539 sprintf( __( 'The file %s could not be removed as the unlink() function is disabled. This is a system configuration issue.', 'wp-2fa' ), $file )
540 );
541 }
542
543 // Suppress unlink warnings and accept the return value; the helper
544 // handles failure paths and this avoids noisy errors on some hosts.
545 $result = @unlink( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.unlink_unlink
546
547 // clearstatcache may not support args on older PHP; ignore PHPCS
548 // warnings for compatibility.
549 @clearstatcache( true, $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
550
551 if ( $result ) {
552 return true;
553 }
554
555 return new \WP_Error(
556 'wp-2fa',
557 sprintf(
558 // translators: the name of the file.
559 __( 'Unable to remove %s due to an unknown error.', 'wp-2fa' ),
560 $file
561 )
562 );
563 }
564
565 /**
566 * Gets the content of a file
567 *
568 * @param string $file - The name of the file.
569 *
570 * @return bool|string
571 *
572 * @since 2.4.0
573 */
574 protected static function get_file_contents( string $file ) {
575 if ( ! self::is_path_allowed( $file ) ) {
576 return false;
577 }
578
579 if ( ! self::exists( $file ) ) {
580 return '';
581 }
582
583 $contents = self::read( $file );
584
585 if ( is_wp_error( $contents ) ) {
586 return false;
587 }
588
589 return $contents;
590 }
591
592 /**
593 * Write the supplied modification to the wp-config.php file.
594 *
595 * @since 2.4.0
596 *
597 * @param string $modification - The modification to add to the wp-config.php file.
598 *
599 * @return bool
600 */
601 private static function write_wp_config( $modification ) {
602 $file_path = self::get_wp_config_file_path();
603
604 return self::update( $file_path, $modification );
605 }
606
607 /**
608 * Updates the content of a file
609 *
610 * @param string $file - The name of the file to update.
611 * @param string $modification - The modification to be added to the file.
612 *
613 * @return boolean
614 *
615 * @since 2.4.0
616 */
617 private static function update( string $file, string $modification ): bool {
618 // Check to make sure that the settings give permission to write files.
619 if ( ! self::can_write_to_file( $file ) ) {
620
621 return false;
622 }
623
624 $contents = self::read( $file );
625
626 if ( is_wp_error( $contents ) ) {
627 return $contents;
628 }
629
630 if ( ! $contents ) {
631 return false;
632 }
633
634 $modification = ltrim( $modification, "\x0B\r\n\0" );
635 $modification = rtrim( $modification, " \t\x0B\r\n\0" );
636
637 if ( empty( $modification ) ) {
638 // If there isn't a new modification, write the content without any modification and return the result.
639
640 if ( empty( $contents ) ) {
641 $contents = PHP_EOL;
642 }
643
644 return false;
645 }
646
647 $placeholder = self::get_placeholder();
648
649 // Ensure that the generated placeholder can be uniquely identified in the contents.
650 while ( false !== strpos( $contents, $placeholder ) ) {
651 $placeholder = self::get_placeholder();
652 }
653
654 // Put the placeholder at the beginning of the file, after the <?php tag.
655 $contents = preg_replace( '/^(.*?<\?(?:php)?)\s*(?:\r\r\n|\r\n|\r|\n)/', "\${1}$placeholder", $contents, 1 );
656
657 if ( false === strpos( $contents, $placeholder ) ) {
658 $contents = preg_replace( '/^(.*?<\?(?:php)?)\s*(.+(?:\r\r\n|\r\n|\r|\n))/', "\${1}$placeholder$2", $contents, 1 );
659 }
660
661 if ( false === strpos( $contents, $placeholder ) ) {
662 $contents = "<?php$placeholder?" . ">$contents";
663 }
664
665 // Pad away from existing sections when adding iThemes Security modifications.
666 $line_ending = self::get_line_ending( $contents );
667
668 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 ) ) {
669 $contents = preg_replace( "/$placeholder/", "$line_ending$placeholder", $contents );
670 }
671 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 ) ) {
672 $contents = preg_replace( "/$placeholder/", "$placeholder$line_ending", $contents );
673 }
674
675 // Ensure that the file ends in a newline if the placeholder is at the end.
676 $contents = preg_replace( "/$placeholder$/", "$placeholder$line_ending", $contents );
677
678 if ( ! empty( $modification ) ) {
679 // Normalize line endings of the modification to match the file's line endings.
680 $modification = self::normalize_line_endings( $modification, $line_ending );
681
682 // Exchange the placeholder with the modification.
683 $contents = preg_replace( "/$placeholder/", $modification, $contents );
684 }
685
686 // Write the new contents to the file and return the results.
687 return self::write( $file, $contents );
688 }
689
690 /**
691 * Create a PHP literal suitable for inclusion inside wp-config.php.
692 *
693 * @param string $secret Secret value to persist.
694 *
695 * @return string Empty string when the value is invalid.
696 *
697 * @since 3.1.0
698 */
699 private static function build_secret_literal( string $secret ): string {
700 $secret = trim( $secret );
701
702 if ( '' === $secret ) {
703 return '';
704 }
705
706 if ( preg_match( '/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $secret ) ) {
707 return '';
708 }
709
710 return var_export( $secret, true ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export
711 }
712
713 /**
714 * Retrieve the regex pattern used to locate the secret definition.
715 *
716 * @return string
717 *
718 * @since 3.1.0
719 */
720 private static function get_secret_definition_pattern(): string {
721 $constant = preg_quote( self::SECRET_NAME, '/' );
722
723 return '/define\s*\(\s*([\'\"])' . $constant . '\1\s*,\s*([\'\"])(.*?)\2\s*\);/is';
724 }
725
726 /**
727 * Replace the stored secret definition with an updated value.
728 *
729 * @param string $contents Full contents of the wp-config.php file.
730 * @param string $definition Sanitized definition to store.
731 *
732 * @return string|false
733 *
734 * @since 3.1.0
735 */
736 private static function replace_secret_definition( string $contents, string $definition ) {
737 $replaced = preg_replace( self::get_secret_definition_pattern(), $definition, $contents, 1 );
738
739 if ( null === $replaced || $replaced === $contents ) {
740 return false;
741 }
742
743 return $replaced;
744 }
745
746 /**
747 * Determine whether a path is inside the allowed data directory or points to wp-config.php.
748 *
749 * @param string $path Absolute file or directory path.
750 *
751 * @return bool
752 *
753 * @since 3.1.0
754 */
755 private static function is_path_allowed( string $path ): bool {
756 $canonical = self::canonicalize_path( $path );
757
758 if ( '' === $canonical ) {
759 return false;
760 }
761
762 $wp_config_path = self::canonicalize_path( self::get_wp_config_file_path() );
763
764 if ( '' !== $wp_config_path && $canonical === $wp_config_path ) {
765 return true;
766 }
767
768 $data_root = self::get_data_root_path();
769
770 if ( '' !== $data_root && self::path_starts_with( $canonical, $data_root ) ) {
771 return true;
772 }
773
774 return false;
775 }
776
777 /**
778 * Return the canonical plugin data root.
779 *
780 * @return string
781 *
782 * @since 3.1.0
783 */
784 private static function get_data_root_path(): string {
785 static $data_root = null;
786
787 if ( null !== $data_root ) {
788 return $data_root;
789 }
790
791 $data_root = '';
792 $upload_dir = wp_upload_dir( null, false );
793
794 if ( is_array( $upload_dir ) && isset( $upload_dir['basedir'] ) ) {
795 $base = $upload_dir['basedir'];
796 } elseif ( defined( 'WP_CONTENT_DIR' ) ) {
797 $base = WP_CONTENT_DIR . \DIRECTORY_SEPARATOR . 'uploads';
798 } else {
799 return $data_root;
800 }
801
802 $data_root = self::canonicalize_path( $base . \DIRECTORY_SEPARATOR . self::WP2FA_UPLOADS_DIR );
803
804 return $data_root;
805 }
806
807 /**
808 * Normalize any given path by removing traversal tokens and normalizing separators.
809 *
810 * @param string $path Raw file or directory path.
811 *
812 * @return string
813 *
814 * @since 3.1.0
815 */
816 private static function canonicalize_path( string $path ): string {
817 if ( '' === $path ) {
818 return '';
819 }
820
821 if ( function_exists( 'wp_normalize_path' ) ) {
822 $path = \wp_normalize_path( $path );
823 } else {
824 $path = str_replace( '\\', '/', $path );
825 }
826
827 $drive = '';
828 $path_start = substr( $path, 0, 2 );
829 if ( preg_match( '/^[A-Za-z]:$/', $path_start ) ) {
830 $drive = strtoupper( $path_start );
831 $path = substr( $path, 2 );
832 }
833
834 $is_absolute = ( 0 === strpos( $path, '/' ) );
835 $segments = explode( '/', trim( $path, '/' ) );
836 $resolved = array();
837
838 foreach ( $segments as $segment ) {
839 if ( '' === $segment || '.' === $segment ) {
840 continue;
841 }
842
843 if ( '..' === $segment ) {
844 array_pop( $resolved );
845 continue;
846 }
847
848 $resolved[] = $segment;
849 }
850
851 $normalized = implode( '/', $resolved );
852
853 if ( $is_absolute ) {
854 $normalized = '/' . ltrim( $normalized, '/' );
855 }
856
857 if ( '' !== $drive ) {
858 $normalized = $drive . $normalized;
859 }
860
861 if ( '' === $normalized ) {
862 return '';
863 }
864
865 if ( '/' === $normalized || preg_match( '/^[A-Za-z]:\/$/', $normalized ) ) {
866 return $normalized;
867 }
868
869 return rtrim( $normalized, '/' );
870 }
871
872 /**
873 * Check if a path is inside a given root directory.
874 *
875 * @param string $path Absolute canonicalized path.
876 * @param string $root Canonicalized root directory.
877 *
878 * @return bool
879 *
880 * @since 3.1.0
881 */
882 private static function path_starts_with( string $path, string $root ): bool {
883 $normalized_root = rtrim( $root, '/' );
884 $normalized_path = rtrim( $path, '/' );
885
886 if ( '' === $normalized_root || '' === $normalized_path ) {
887 return false;
888 }
889
890 $normalized_root .= '/';
891 $normalized_path .= '/';
892
893 return 0 === strpos( $normalized_path, $normalized_root );
894 }
895
896 /**
897 * Sanitize a relative path segment appended to the data directory.
898 *
899 * @param string $path Raw subpath requested by the caller.
900 *
901 * @return string|null Empty string for blank input, null when invalid.
902 *
903 * @since 3.1.0
904 */
905 private static function sanitize_relative_subpath( string $path ): ?string {
906 $path = trim( $path );
907
908 if ( '' === $path ) {
909 return '';
910 }
911
912 if ( function_exists( 'wp_normalize_path' ) ) {
913 $path = \wp_normalize_path( $path );
914 } else {
915 $path = str_replace( '\\', '/', $path );
916 }
917
918 $path = trim( $path, '/\\' );
919 $segments = explode( '/', $path );
920 $sanitized = array();
921
922 foreach ( $segments as $segment ) {
923 if ( '' === $segment || '.' === $segment || '..' === $segment ) {
924 return null;
925 }
926
927 if ( ! preg_match( '/^[A-Za-z0-9_-]+$/', $segment ) ) {
928 return null;
929 }
930
931 $sanitized[] = $segment;
932 }
933
934 return implode( \DIRECTORY_SEPARATOR, $sanitized );
935 }
936
937 /**
938 * Generates unique placeholder to be used in the string
939 *
940 * @return string
941 *
942 * @since 2.4.0
943 */
944 private static function get_placeholder(): string {
945 $characters = str_split( 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' );
946
947 $string = '';
948
949 for ( $x = 0; $x < 100; $x++ ) {
950 $string .= array_rand( $characters );
951 }
952
953 return $string;
954 }
955
956 /**
957 * Returns to proper line endings of a given content
958 *
959 * @param string $contents - The text to be checked.
960 *
961 * @return string
962 *
963 * @since 2.4.0
964 */
965 private static function get_line_ending( string $contents ) {
966 if ( empty( $contents ) ) {
967 return PHP_EOL;
968 }
969
970 $count["\n"] = preg_match_all( "/(?<!\r)\n/", $contents, $matches );
971 $count["\r"] = preg_match_all( "/\r(?!\n)/", $contents, $matches );
972 $count["\r\n"] = preg_match_all( "/(?<!\r)\r\n/", $contents, $matches );
973 $count["\r\r\n"] = preg_match_all( "/\r\r\n/", $contents, $matches );
974
975 if ( 0 === array_sum( $count ) ) {
976 return PHP_EOL;
977 }
978
979 $maxes = array_keys( $count, max( $count ), true );
980
981 if ( in_array( "\r\r\n", $maxes, true ) ) {
982 return "\r\r\n";
983 }
984
985 return $maxes[0];
986 }
987
988 /**
989 * Normalizing fileendings for different platforms
990 *
991 * @param string $content - The file content to be checked.
992 * @param string $line_ending - Line endings to be used.
993 *
994 * @return string
995 *
996 * @since 2.4.0
997 */
998 private static function normalize_line_endings( string $content, string $line_ending = "\n" ): string {
999 return preg_replace( '/(?<!\r)\n|\r(?!\n)|(?<!\r)\r\n|\r\r\n/', $line_ending, $content );
1000 }
1001
1002 /**
1003 * Reads the content of a file
1004 *
1005 * @param string $file - The file to read.
1006 *
1007 * @return bool|string
1008 *
1009 * @since 2.4.0
1010 */
1011 private static function read( string $file ) {
1012 if ( ! self::is_path_allowed( $file ) ) {
1013 return false;
1014 }
1015
1016 if ( ! is_file( $file ) ) {
1017 return false;
1018 }
1019
1020 $callable = array();
1021
1022 if ( PHP_Helper::is_callable( 'file_get_contents' ) ) {
1023 $callable[] = 'file_get_contents';
1024 }
1025 if ( PHP_Helper::is_callable( 'fopen' ) && PHP_Helper::is_callable( 'feof' ) && PHP_Helper::is_callable( 'fread' ) && PHP_Helper::is_callable( 'flock' ) ) {
1026 $callable[] = 'fopen';
1027 }
1028
1029 if ( empty( $callable ) ) {
1030 return false;
1031 }
1032
1033 $contents = false;
1034
1035 // Different permissions to try in case the starting set of permissions are prohibiting read.
1036 $trial_perms = array(
1037 false,
1038 0644,
1039 0664,
1040 0666,
1041 );
1042
1043 foreach ( $trial_perms as $perms ) {
1044 if ( false !== $perms ) {
1045 if ( ! isset( $original_file_perms ) ) {
1046 $original_file_perms = self::get_permissions( $file );
1047 }
1048
1049 self::chmod( $file, $perms );
1050 }
1051
1052 if ( in_array( 'fopen', $callable, true ) ) {
1053 // Assignment in the conditional is intentional to attempt open
1054 // and capture the handle in one expression; ignore PHPCS here.
1055 if ( false !== ( $fh = @fopen( $file, 'rb' ) ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen, Generic.CodeAnalysis.AssignmentInCondition.Found, Squiz.PHP.DisallowMultipleAssignments.FoundInControlStructure
1056 flock( $fh, LOCK_SH );
1057
1058 $contents = '';
1059
1060 while ( ! feof( $fh ) ) {
1061 // fread can trigger warnings on read errors; we deliberately
1062 // manage return values and ignore PHPCS for the raw call.
1063 $contents .= fread( $fh, 1024 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread
1064 }
1065
1066 flock( $fh, LOCK_UN );
1067 // Close the handle and ignore potential warnings; errors are
1068 // handled by higher-level checks.
1069 fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
1070 }
1071 }
1072
1073 if ( ( false === $contents ) && in_array( 'file_get_contents', $callable, true ) ) {
1074 // file_get_contents used as a fallback for reading files; suppress
1075 // PHPCS warnings about remote-get usage in this low-level helper.
1076 $contents = file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
1077 }
1078
1079 if ( false !== $contents ) {
1080 if ( isset( $original_file_perms ) && is_int( $original_file_perms ) ) {
1081 // Reset the original file permissions if they were modified.
1082 self::chmod( $file, $original_file_perms );
1083 }
1084
1085 return $contents;
1086 }
1087 }
1088
1089 return false;
1090 }
1091
1092 /**
1093 * Changes the permissions of a file
1094 *
1095 * @param string $file - The file to change permissions to.
1096 * @param mixed $perms - The permissions to be set.
1097 *
1098 * @return bool
1099 *
1100 * @since 2.4.0
1101 */
1102 private static function chmod( string $file, $perms ): bool {
1103 if ( ! is_int( $perms ) ) {
1104 return \CURLOPT_SSL_FALSESTART;
1105 }
1106
1107 if ( ! PHP_Helper::is_callable( 'chmod' ) ) {
1108 return false;
1109 }
1110
1111 // Attempt chmod and suppress warnings; we handle return values and
1112 // do not want noise when permissions cannot be changed.
1113 return @chmod( $file, $perms ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_chmod
1114 }
1115
1116 /**
1117 * Returns the default filesystem permissions
1118 *
1119 * @return integer
1120 *
1121 * @since 2.4.0
1122 */
1123 private static function get_default_permissions() {
1124
1125 $perms = self::get_permissions( ABSPATH );
1126
1127 if ( ! is_wp_error( $perms ) ) {
1128 return $perms;
1129 }
1130
1131 return 0755;
1132 }
1133 }
1134 }
1135