PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 6.2.2
Defender Security – Malware Scanner, Login Security & Firewall v6.2.2
6.3.0 6.2.3 6.2.4 6.2.0 6.2.1 6.2.2 6.1.0 5.3.1 5.4.0 5.4.1 5.5.0 5.5.1 5.6.0 5.6.1 5.6.2 5.7.0 5.7.1 5.7.2 5.8.0 5.8.1 5.9.0 6.0.0 6.0.1 3.0.1 3.1.0 All 141 releases
defender-security / src / controller / class-main-setting.php

class-main-setting.php in Defender Security – Malware Scanner, Login Security & Firewall 6.2.2, at src/controller/class-main-setting.php

1,409 lines 38.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Handles the main settings.
4 *
5 * @package WP_Defender\Controller
6 */
7
8 namespace WP_Defender\Controller;
9
10 use Countable;
11 use WP_Defender\Event;
12 use Calotes\Component\Request;
13 use Calotes\Component\Response;
14 use WP_Defender\Behavior\WPMUDEV;
15 use WP_Defender\Controller\Notification;
16 use WP_Defender\Component\Backup_Settings;
17 use WP_Defender\Component\Config\Config_Adapter;
18 use WP_Defender\Component\Config\Local_Config_Store;
19 use WP_Defender\Component\Network_Cron_Manager;
20 use WP_Defender\Traits\Defender_Dashboard_Client;
21 use WP_Defender\Component\Config\Config_Hub_Helper;
22 use WP_Defender\Model\Setting\Main_Setting as Model_Main_Setting;
23 use WP_Filesystem_Base;
24 use WP_Defender\Controller\Security_Headers;
25 use WP_Defender\Controller\Firewall;
26
27 /**
28 * Methods for handling main settings.
29 */
30 class Main_Setting extends Event {
31 use Defender_Dashboard_Client;
32
33 /**
34 * The slug identifier for this controller.
35 *
36 * @var string
37 */
38 public $slug = 'wdf-setting';
39
40 /**
41 * The model for handling the data.
42 *
43 * @var Model_Main_Setting
44 */
45 public $model;
46
47 /**
48 * Service for handling logic.
49 *
50 * @var Backup_Settings
51 */
52 protected $service;
53
54 /**
55 * Service for handling WPMUDEV.
56 *
57 * @var WPMUDEV
58 */
59 protected $wpmudev;
60
61 /**
62 * Local configs store.
63 *
64 * @var Local_Config_Store
65 */
66 protected $local_store;
67
68 /**
69 * The intention/nonce action of the current request.
70 *
71 * @since 4.0.0
72 * @var string
73 */
74 protected $intention = '';
75
76 /**
77 * Initializes the model and service, registers routes, and sets up scheduled events if the model is active.
78 */
79 public function __construct() {
80 $this->register_page(
81 esc_html__( 'Settings', 'defender-security' ),
82 $this->slug,
83 array( $this, 'main_view' ),
84 $this->parent_slug
85 );
86
87 // Internal cache.
88 $this->model = new Model_Main_Setting();
89 $this->service = wd_di()->get( Backup_Settings::class );
90 $this->wpmudev = wd_di()->get( WPMUDEV::class );
91 $this->local_store = new Local_Config_Store( $this->service );
92 add_action( 'defender_enqueue_assets', array( $this, 'enqueue_assets' ) );
93 $this->register_routes();
94
95 /**
96 * Add cron schedule to clean out outdated logs.
97 *
98 * @var Network_Cron_Manager $network_cron_manager
99 */
100 $network_cron_manager = wd_di()->get( Network_Cron_Manager::class );
101 $network_cron_manager->register_callback(
102 'wp_defender_clear_logs',
103 array( $this, 'clear_logs' ),
104 DAY_IN_SECONDS,
105 time() + HOUR_IN_SECONDS
106 );
107 add_action( 'wd_settings_update', array( $this, 'intercept_settings_update' ), 10, 2 );
108 // Initialize Security Headers so its routes are registered on every request,
109 // including AJAX calls from the Security Policies tab.
110 wd_di()->get( Security_Headers::class );
111 // Initialize Firewall so its routes (sync_ip_header, save_settings, empty_logs, etc.)
112 // are registered on AJAX requests originating from the Tools tab.
113 // Guarded to AJAX only to prevent a duplicate admin menu entry on normal page loads.
114 if ( wp_doing_ajax() ) {
115 wd_di()->get( Firewall::class );
116 wd_di()->get( Audit_Logging::class );
117 }
118 }
119
120 /**
121 * Check actual config data.
122 *
123 * @return Response
124 * @defender_route
125 */
126 public function check_configs(): Response {
127 Config_Hub_Helper::clear_config_transient();
128
129 return new Response( true, array() );
130 }
131
132 /**
133 * Safe way to get cached model.
134 *
135 * @return Model_Main_Setting
136 */
137 private function get_model() {
138 if ( is_object( $this->model ) ) {
139 return $this->model;
140 }
141
142 return wd_di()->get( Model_Main_Setting::class );
143 }
144
145 /**
146 * Enqueues scripts and styles for this page.
147 * Only enqueues assets if the page is active.
148 */
149 public function enqueue_assets() {
150 if ( ! $this->is_page_active() ) {
151 return;
152 }
153
154 $handle = 'defender-ui-settings';
155 wp_enqueue_script(
156 $handle,
157 WP_DEFENDER_BASE_URL . 'assets/js/settings-ui.js',
158 array( 'def-vue', 'def-manifest', 'def-core-ui', 'defender', 'wp-i18n' ),
159 DEFENDER_VERSION,
160 true
161 );
162 wp_set_script_translations( $handle, 'wpdef' );
163
164 $data = $this->data_frontend();
165 $routes = $data['routes'] ?? array();
166 $nonces = $data['nonces'] ?? array();
167 unset( $data['routes'], $data['nonces'] );
168
169 // Include Security Headers data for the Security Policies tab.
170 $sh_controller = wd_di()->get( Security_Headers::class );
171 $sh_data = $sh_controller->data_frontend();
172 $sh_routes = $sh_data['routes'] ?? array();
173 $sh_nonces = $sh_data['nonces'] ?? array();
174 unset( $sh_data['routes'], $sh_data['nonces'] );
175
176 // Namespace security headers routes/nonces to avoid key collisions.
177 foreach ( $sh_routes as $key => $route ) {
178 $routes[ 'security_headers_' . $key ] = $route;
179 }
180 foreach ( $sh_nonces as $key => $nonce ) {
181 $nonces[ 'security_headers_' . $key ] = $nonce;
182 }
183
184 // Include Module data for the Tools tab.
185 $scan_controller = wd_di()->get( Scan::class );
186 $firewall_controller = wd_di()->get( Firewall::class );
187 $audit_logging_controller = wd_di()->get( Audit_Logging::class );
188 $blocklist_monitor_controller = wd_di()->get( Blocklist_Monitor::class );
189 $notification_controller = wd_di()->get( Notification::class );
190 $recipients_controller = wd_di()->get( Recipients::class );
191 wp_localize_script(
192 $handle,
193 'defenderUIData',
194 array_merge(
195 $this->get_shared_data(),
196 // Specific data.
197 array(
198 'settings' => array_merge( $data, array( 'security_headers' => $sh_data ) ),
199 'scan' => $scan_controller->data_frontend(),
200 'firewall' => $firewall_controller->data_frontend(),
201 'audit_logging' => $audit_logging_controller->settings_data_frontend(),
202 'blocklist_monitor' => $blocklist_monitor_controller->data_frontend(),
203 'notification' => $notification_controller->data_frontend(),
204 'recipients' => $recipients_controller->data_frontend(),
205 'routes' => $routes,
206 'nonces' => $nonces,
207 )
208 )
209 );
210
211 wp_enqueue_style(
212 $handle,
213 WP_DEFENDER_BASE_URL . 'assets/css/showcase.css',
214 array(),
215 DEFENDER_VERSION
216 );
217
218 $this->enqueue_main_assets();
219 }
220
221 /**
222 * Render the root element for frontend.
223 *
224 * @return void
225 */
226 public function main_view(): void {
227 $this->render( 'main' );
228 }
229
230 /**
231 * Save settings.
232 *
233 * @param Request $request The request object containing new settings data.
234 *
235 * @return Response
236 * @defender_route
237 */
238 public function save_settings( Request $request ) {
239 $model = $this->get_model();
240 $data = $request->get_data();
241
242 $model->import( $data );
243 if ( $model->validate() ) {
244 $this->set_intention( 'Settings' );
245 $model->save();
246 Config_Hub_Helper::set_clear_active_flag();
247
248 return new Response(
249 true,
250 array(
251 'message' => esc_html__( 'Your settings have been updated.', 'defender-security' ),
252 'auto_close' => true,
253 )
254 );
255 }
256
257 return new Response(
258 false,
259 array(
260 'message' => $model->get_formatted_errors(),
261 )
262 );
263 }
264
265 /**
266 * Reset settings.
267 *
268 * @return Response
269 * @defender_route
270 */
271 public function reset_settings(): Response {
272 $preserve_settings = 'preserve' === $this->get_model()->uninstall_settings;
273
274 wd_di()->get( Login_Access::class )->remove_settings();
275 wd_di()->get( Blocklist_Monitor::class )->remove_settings();
276 wd_di()->get( Dashboard::class )->remove_settings();
277 wd_di()->get( Security_Tweaks::class )->remove_settings();
278 wd_di()->get( Scan::class )->remove_settings();
279 // Parent and submodules.
280 wd_di()->get( Firewall::class )->remove_settings();
281
282 wd_di()->get( Mask_Login::class )->remove_settings();
283 wd_di()->get( Notification::class )->remove_settings();
284 wd_di()->get( Two_Factor::class )->remove_settings();
285 wd_di()->get( Blocklist_Monitor::class )->remove_settings();
286 wd_di()->get( Data_Tracking::class )->remove_settings();
287 wd_di()->get( \WP_Defender\Controller\Setup_Wizard::class )->remove_settings();
288 wd_di()->get( \WP_Defender\Controller\Activity_Log::class )->remove_settings();
289
290 $this->set_intention( 'Data Reset' );
291 // Track first until settings are removed.
292 $this->track_opt( false );
293 $this->remove_settings();
294 if ( ! $preserve_settings ) {
295 update_site_option( Scan::FIRST_SCAN_STARTED, '0' );
296 }
297 // Indicate that it is not a new installation.
298 defender_no_fresh_install();
299
300 return new Response(
301 true,
302 array(
303 'message' => esc_html__( 'Your settings have been reset.', 'defender-security' ),
304 'redirect' => network_admin_url( 'admin.php?page=wp-defender' ),
305 'interval' => 1,
306 )
307 );
308 }
309
310 /**
311 * Tracks the settings toggle.
312 *
313 * @param bool $active The status of the toggle.
314 *
315 * @return void
316 */
317 public function track_opt( $active ) {
318 $model = $this->get_model();
319 // Track only if the Data tracking option was enabled before changes.
320 if ( $model->usage_tracking ) {
321 $from = $this->get_triggered_location();
322 $this->track_opt_toggle( $active, $from );
323 }
324 }
325
326 /**
327 * Removes settings for all submodules.
328 */
329 public function remove_settings(): void {
330 wd_di()->get( Model_Main_Setting::class )->delete();
331 }
332
333 /**
334 * Delete all the data & the cache.
335 */
336 public function remove_data() {
337 }
338
339 /**
340 * Provides data for the frontend.
341 *
342 * @return array An array of data for the frontend.
343 */
344 public function data_frontend(): array {
345 $model = $this->get_model();
346
347 $this->service->maybe_create_default_config();
348 $configs = $this->local_store->get_frontend_configs();
349
350 $link = $this->wpmudev->is_member()
351 ? 'https://wpmudev.com/translate/projects/wpdef/'
352 : 'https://translate.wordpress.org/projects/wp-plugins/defender-security/';
353 $can_whitelabel = wd_di()->get( \WP_Defender\Integrations\Dashboard_Whitelabel::class )->can_whitelabel();
354
355 return array_merge(
356 array(
357 'general' => array(
358 'translate' => $model->translate,
359 'show_usage_tracking' => $this->wpmudev->is_wpmu_dev_admin() || ! $can_whitelabel,
360 'usage_tracking' => $model->usage_tracking,
361 'translation_link' => $link,
362 ),
363 'data_settings' => array(
364 'uninstall_settings' => $model->uninstall_settings,
365 'uninstall_data' => $model->uninstall_data,
366 'uninstall_quarantine' => $model->uninstall_quarantine,
367 ),
368 'accessibility' => array(
369 'high_contrast_mode' => $model->high_contrast_mode,
370 ),
371 'misc' => array(
372 'setting_url' => network_admin_url( is_multisite() ? 'settings.php' : 'options-general.php' ),
373 'privacy_link' => Model_Main_Setting::PRIVACY_LINK,
374 ),
375 'configs' => $configs,
376 'hub_connector' => wd_di()->get( Hub_Connector::class )->data_frontend(),
377 'antibot' => wd_di()->get( Antibot_Global_Firewall::class )->data_frontend(),
378 ),
379 $this->dump_routes_and_nonces()
380 );
381 }
382
383 /**
384 * Converts the current object state to an array.
385 *
386 * @return array The array representation of the object.
387 */
388 public function to_array(): array {
389 return array();
390 }
391
392 /**
393 * Imports data into the model.
394 *
395 * @param array $data Data to be imported into the model.
396 */
397 public function import_data( array $data ) {
398 $model = $this->get_model();
399
400 // Preserve Usage Tracking; it is a user privacy choice that a config must not change.
401 $current_usage_tracking = $model->usage_tracking;
402
403 $model->import( $data );
404 $model->usage_tracking = $current_usage_tracking;
405
406 if ( $model->validate() ) {
407 $model->save();
408 }
409 }
410
411 /**
412 * Validates the importer data.
413 * This function checks if the importer data is valid by verifying its configuration data and comparing it with the
414 * sample data.
415 *
416 * @param array $importer The importer data to be validated.
417 *
418 * @return bool Returns true if the importer data is valid, false otherwise.
419 */
420 private function validate_importer( $importer ): bool {
421 return $this->service->verify_config_data( $importer );
422 }
423
424 /**
425 * Import config.
426 *
427 * @return Response
428 * @defender_route
429 */
430 public function import_config(): Response {
431 global $wp_filesystem;
432 // Initialize the WP filesystem, no more using 'file-put-contents' function.
433 if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) {
434 require_once ABSPATH . '/wp-admin/includes/file.php';
435 WP_Filesystem();
436 }
437 $file = defender_get_data_from_request( 'file', 'f' );
438 $tmp = $file['tmp_name'];
439 $content = $wp_filesystem->get_contents( $tmp );
440 $importer = json_decode( $content, true );
441 if ( ! is_array( $importer ) ) {
442 return new Response(
443 false,
444 array(
445 'message' => esc_html__( 'The file is corrupted.', 'defender-security' ),
446 )
447 );
448 }
449
450 if ( ! $this->validate_importer( $importer ) ) {
451 return new Response(
452 false,
453 array(
454 'message' => esc_html__(
455 'An error occurred while importing the file. Please check your file or upload another file.',
456 'defender-security'
457 ),
458 )
459 );
460 }
461
462 // Do not use wp_strip_all_tags() to prevent XSS attack.
463 $name = sanitize_text_field( $importer['name'] );
464 $this->local_store->add_imported( $importer );
465
466 // Detect Pro-only modules that were skipped during validation
467 // to inform Free users that some settings were not imported.
468 $response_data = array(
469 'message' => sprintf(
470 /* translators: %s: Config name. */
471 esc_html__(
472 '%s config has been uploaded successfully – you can now apply it to this site.',
473 'defender-security'
474 ),
475 '<strong>' . $name . '</strong>'
476 ),
477 'configs' => $this->local_store->get_frontend_configs(),
478 );
479
480 if ( ! $this->wpmudev->is_pro() ) {
481 $sample = $this->service->gather_data();
482 $pro_modules = array();
483
484 foreach ( $importer['configs'] as $slug => $module ) {
485 // Modules present in the imported config but NOT in Free's sample data are Pro-only.
486 if ( ! isset( $sample[ $slug ] ) ) {
487 $pro_modules[] = $slug;
488 }
489 }
490
491 if ( array() !== $pro_modules ) {
492 $module_names = array_map(
493 function ( $slug ) {
494 $names = array(
495 'audit' => esc_html__( 'Audit Logs', 'defender-security' ),
496 'waf' => esc_html__( 'Web Application Firewall (WAF)', 'defender-security' ),
497 'blocklist_monitor' => esc_html__( 'Blocklist Monitor', 'defender-security' ),
498 'pwned_passwords' => esc_html__( 'Pwned Passwords', 'defender-security' ),
499 'force_strong_password' => esc_html__( 'Strong Passwords', 'defender-security' ),
500 'session_protection' => esc_html__( 'Session Protection', 'defender-security' ),
501 'two_factor' => esc_html__( 'Two-Factor Authentication', 'defender-security' ),
502 'mask_login' => esc_html__( 'Hide Login URL', 'defender-security' ),
503 'security_headers' => esc_html__( 'Security Policies', 'defender-security' ),
504 );
505
506 return $names[ $slug ] ?? $slug;
507 },
508 $pro_modules
509 );
510
511 $response_data['message'] .= ' ' . sprintf(
512 /* translators: %s: Comma-separated list of Pro-only feature names. */
513 esc_html__(
514 'This config included features not available in the Free version: %s. Only the available settings were imported.',
515 'defender-security'
516 ),
517 implode( ', ', $module_names )
518 );
519 }
520 }
521
522 return new Response(
523 true,
524 $response_data
525 );
526 }
527
528 /**
529 * Create config.
530 *
531 * @param Request $request The request object containing new config data.
532 *
533 * @return Response
534 * @defender_route
535 */
536 public function new_config( Request $request ): Response {
537 $data = $request->get_data();
538 $name = trim( $data['name'] );
539 if ( '' === $name ) {
540 return new Response(
541 false,
542 array(
543 'message' => esc_html__( 'Invalid config name', 'defender-security' ),
544 )
545 );
546 }
547 $name = sanitize_text_field( $name );
548 $desc = '';
549 if ( isset( $data['desc'] ) && is_string( $data['desc'] ) && '' !== trim( $data['desc'] ) ) {
550 $desc = wp_kses_post( $data['desc'] );
551 } elseif ( isset( $data['description'] ) && is_string( $data['description'] ) && '' !== trim( $data['description'] ) ) {
552 $desc = wp_kses_post( $data['description'] );
553 }
554 $note_added_time = isset( $data['note_added_time'] ) && is_scalar( $data['note_added_time'] )
555 ? sanitize_text_field( (string) $data['note_added_time'] )
556 : '';
557 $this->local_store->create_from_current( $name, $desc, $note_added_time );
558
559 return new Response(
560 true,
561 array(
562 'message' => sprintf(
563 /* translators: %s: Config name. */
564 esc_html__( '%s config saved successfully.', 'defender-security' ),
565 '<strong>' . $name . '</strong>'
566 ),
567 'configs' => $this->local_store->get_frontend_configs(),
568 )
569 );
570 }
571
572 /**
573 * Download config
574 *
575 * @return Response|void
576 * @defender_route
577 */
578 public function download_config() {
579 $key = defender_get_data_from_request( 'key', 'g' );
580 if ( ! is_string( $key ) || '' === trim( $key ) ) {
581 return new Response(
582 false,
583 array(
584 'message' => esc_html__( 'Invalid config', 'defender-security' ),
585 )
586 );
587 }
588
589 $config = $this->local_store->get_full_for_key( $key );
590 if ( null === $config ) {
591 $config = $this->prepare_config_for_download( $key );
592 }
593 if ( null === $config ) {
594 return new Response(
595 false,
596 array(
597 'message' => esc_html__( 'Invalid config', 'defender-security' ),
598 )
599 );
600 }
601
602 $filename = 'wp-defender-config-' . sanitize_file_name( $config['name'] ) . '.json';
603 header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
604 echo wp_json_encode( $config, JSON_PRETTY_PRINT );
605 exit();
606 }
607
608 /**
609 * Download multiple configs as a zip file.
610 *
611 * @return Response|void
612 * @defender_route
613 */
614 public function download_configs_zip() {
615 $keys_raw = defender_get_data_from_request( 'keys', 'g' );
616 if ( ! is_string( $keys_raw ) || '' === trim( $keys_raw ) ) {
617 return new Response(
618 false,
619 array(
620 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
621 )
622 );
623 }
624
625 $decoded = json_decode( wp_unslash( $keys_raw ), true );
626 $keys = is_array( $decoded ) ? $decoded : explode( ',', $keys_raw );
627 $keys = array_values(
628 array_filter(
629 array_map(
630 static function ( $key ) {
631 return is_string( $key ) ? trim( $key ) : '';
632 },
633 $keys
634 ),
635 'boolval'
636 )
637 );
638
639 if ( array() === $keys ) {
640 return new Response(
641 false,
642 array(
643 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
644 )
645 );
646 }
647
648 if ( ! class_exists( 'ZipArchive' ) ) {
649 return new Response(
650 false,
651 array(
652 'message' => esc_html__( 'Zip archive is not supported on this server.', 'defender-security' ),
653 )
654 );
655 }
656
657 $tmp_zip_path = wp_tempnam( 'wp-defender-configs-' . time() . '.zip' );
658 if ( false === $tmp_zip_path ) {
659 return new Response(
660 false,
661 array(
662 'message' => esc_html__( 'Could not prepare zip file.', 'defender-security' ),
663 )
664 );
665 }
666
667 $cleanup_tmp_zip = static function () use ( $tmp_zip_path ) {
668 if ( file_exists( $tmp_zip_path ) ) {
669 wp_delete_file( $tmp_zip_path );
670 }
671 };
672
673 $zip = new \ZipArchive();
674 if ( true !== $zip->open( $tmp_zip_path, \ZipArchive::OVERWRITE ) ) {
675 $cleanup_tmp_zip();
676
677 return new Response(
678 false,
679 array(
680 'message' => esc_html__( 'Could not create zip file.', 'defender-security' ),
681 )
682 );
683 }
684
685 $has_entries = false;
686
687 foreach ( $keys as $key ) {
688 $config = $this->local_store->get_full_for_key( $key );
689 if ( null === $config ) {
690 $config = $this->prepare_config_for_download( $key );
691 }
692 if ( null === $config ) {
693 continue;
694 }
695
696 $file_name = $this->make_unique_zip_entry_name(
697 'wp-defender-config-' . sanitize_file_name( $config['name'] ),
698 $key,
699 $zip
700 );
701 if ( false === $zip->addFromString( $file_name, wp_json_encode( $config, JSON_PRETTY_PRINT ) ) ) {
702 $zip->close();
703 $cleanup_tmp_zip();
704
705 return new Response(
706 false,
707 array(
708 'message' => esc_html__( 'Could not prepare zip file.', 'defender-security' ),
709 )
710 );
711 }
712
713 $has_entries = true;
714 }
715
716 $zip->close();
717
718 if ( ! $has_entries ) {
719 $cleanup_tmp_zip();
720
721 return new Response(
722 false,
723 array(
724 'message' => esc_html__( 'No valid configs found to export.', 'defender-security' ),
725 )
726 );
727 }
728
729 $download_name = 'wp-defender-configs-' . gmdate( 'Ymd-His' ) . '.zip';
730 header( 'Content-Type: application/zip' );
731 header( 'Content-Disposition: attachment; filename="' . $download_name . '"' );
732 header( 'Content-Length: ' . filesize( $tmp_zip_path ) );
733 readfile( $tmp_zip_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
734 $cleanup_tmp_zip();
735 exit();
736 }
737
738 /**
739 * Apply config.
740 *
741 * @param Request $request The request object containing new config data.
742 *
743 * @return Response
744 * @defender_route
745 */
746 public function apply_config( Request $request ) {
747 $data = $request->get_data();
748 $key = trim( $data['key'] );
749 if ( '' === $key ) {
750 return new Response(
751 false,
752 array(
753 'message' => esc_html__( 'Invalid config', 'defender-security' ),
754 )
755 );
756 }
757
758 $config = $this->local_store->get_full_for_key( $key );
759 if ( null === $config ) {
760 $config = get_site_option( $key );
761 }
762 if ( false === $config || null === $config ) {
763 return new Response(
764 false,
765 array(
766 'message' => esc_html__( 'Invalid config', 'defender-security' ),
767 )
768 );
769 }
770 // Return error message or bool value for auth action.
771 $restore_result = $this->service->restore_data( $config['configs'], 'plugin' );
772 if ( is_string( $restore_result ) ) {
773 return $this->apply_config_recommendations_error_message();
774 }
775
776 $this->local_store->apply( $key );
777 $this->service->make_config_active( $key );
778 // Track.
779 $this->track_feature(
780 'def_config_applied',
781 array(
782 // The check is based on the fact that the Default config cannot be deleted.
783 'Config Type' => isset( $config['is_removable'] ) && false === $config['is_removable'] ? 'Default' : 'Custom',
784 )
785 );
786
787 $message = sprintf(
788 /* translators: %s: Config name. */
789 esc_html__(
790 '%s config has been applied successfully.',
791 'defender-security'
792 ),
793 '<strong>' . $config['name'] . '</strong>'
794 );
795 $return = array();
796 if ( $restore_result ) {
797 $login_url = wp_login_url();
798 $settings_mask_login = new \WP_Defender\Model\Setting\Mask_Login();
799 if ( $settings_mask_login->is_active() ) {
800 $login_url = $settings_mask_login->get_new_login_url();
801 }
802 $message .= '<br/>' . sprintf(
803 /* translators: %s: Login link. */
804 esc_html__(
805 'Due to currently applied security recommendations, you will now need to %s.',
806 'defender-security'
807 ),
808 '<a href="' . $login_url . '"><strong>' . esc_html__( 're-login', 'defender-security' ) . '</strong></a>'
809 );
810 $message .= '<br/>';
811 $message .= esc_html__( 'This will auto reload now.', 'defender-security' );
812
813 $return['reload'] = 3;
814 $redirect = rawurlencode( network_admin_url( 'admin.php?page=wdf-setting&view=configs' ) );
815 if ( isset( $data['screen'] ) && 'dashboard' === $data['screen'] ) {
816 $redirect = rawurlencode( network_admin_url( 'admin.php?page=wp-defender' ) );
817 }
818 $return['redirect'] = add_query_arg(
819 'redirect_to',
820 $redirect,
821 $login_url
822 );
823 $return['interval'] = 2;
824 }
825
826 $return['message'] = $message;
827 $return['auto_close'] = true;
828 $return['configs'] = $this->local_store->get_frontend_configs();
829
830 return new Response( true, $return );
831 }
832
833 /**
834 * Update config.
835 *
836 * @param Request $request The request object containing new config data.
837 *
838 * @return Response
839 * @defender_route
840 */
841 public function update_config( Request $request ) {
842 $data = $request->get_data();
843 $key = trim( $data['key'] );
844 $key = $this->resolve_config_key( $key );
845 $name = trim( $data['name'] );
846 $description = isset( $data['desc'] ) ? trim( (string) $data['desc'] ) : '';
847 if ( '' === $description && isset( $data['description'] ) ) {
848 $description = trim( (string) $data['description'] );
849 }
850 $note_added_time = isset( $data['note_added_time'] ) && is_scalar( $data['note_added_time'] )
851 ? sanitize_text_field( (string) $data['note_added_time'] )
852 : null;
853 if ( '' === $name || '' === $key ) {
854 return new Response(
855 false,
856 array(
857 'message' => esc_html__( 'Invalid config', 'defender-security' ),
858 )
859 );
860 }
861
862 $config = $this->local_store->get_one( $key );
863 if ( null === $config ) {
864 $config = get_site_option( $key );
865 }
866 if ( false === $config || null === $config ) {
867 return new Response(
868 false,
869 array(
870 'message' => esc_html__( 'Invalid config', 'defender-security' ),
871 )
872 );
873 }
874
875 $option_updated = $this->local_store->update_metadata( $key, $name, $description, $note_added_time );
876 if ( ! $option_updated ) {
877 $old_config = $config;
878 $config['name'] = sanitize_text_field( $name );
879 $config['description'] = sanitize_textarea_field( $description );
880 if ( null !== $note_added_time ) {
881 $config['note_added_time'] = $note_added_time;
882 }
883 $option_updated = update_site_option( $key, $config );
884 }
885
886 if ( $option_updated ) {
887 return new Response(
888 true,
889 array(
890 'message' => sprintf(
891 /* translators: %s: Config name. */
892 esc_html__( '%s config saved successfully.', 'defender-security' ),
893 '<strong>' . $name . '</strong>'
894 ),
895 'auto_close' => true,
896 'configs' => $this->local_store->get_frontend_configs(),
897 )
898 );
899 } else {
900 return new Response(
901 false,
902 array(
903 'message' => esc_html__(
904 'An error occurred while saving your config. Please try it again.',
905 'defender-security'
906 ),
907 )
908 );
909 }
910 }
911
912 /**
913 * Sync config metadata list.
914 *
915 * This method updates name/description/note timestamp for provided configs
916 * without changing config payload values.
917 *
918 * @param Request $request The request object containing configs metadata list.
919 *
920 * @return Response
921 * @defender_route
922 */
923 public function sync_configs_metadata( Request $request ): Response {
924 // Defense in depth: central routing already validates nonce and access.
925 if ( ! $this->check_permission() ) {
926 return new Response(
927 false,
928 array(
929 'message' => esc_html__( 'You shall not pass.', 'defender-security' ),
930 )
931 );
932 }
933
934 $data = $request->get_data();
935 if ( ! isset( $data['configs'] ) || ! is_array( $data['configs'] ) ) {
936 return new Response(
937 false,
938 array(
939 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
940 )
941 );
942 }
943
944 $stored_configs = $this->local_store->get_all();
945 foreach ( $data['configs'] as $item ) {
946 if ( ! is_array( $item ) ) {
947 return new Response(
948 false,
949 array(
950 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
951 )
952 );
953 }
954
955 $key = '';
956 if ( isset( $item['key'] ) && is_string( $item['key'] ) ) {
957 $key = trim( $item['key'] );
958 } elseif ( isset( $item['id'] ) && is_string( $item['id'] ) ) {
959 $key = trim( $item['id'] );
960 }
961 $key = $this->resolve_config_key( $key, $stored_configs );
962
963 if (
964 '' === $key ||
965 0 !== strpos( $key, 'wp_defender_config' ) ||
966 ! isset( $stored_configs[ $key ] )
967 ) {
968 return new Response(
969 false,
970 array(
971 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
972 )
973 );
974 }
975
976 $config = $this->local_store->get_one( $key );
977 if ( null === $config ) {
978 continue;
979 }
980
981 $name = isset( $item['name'] ) ? (string) $item['name'] : null;
982 $description = null;
983 $note_added_time = null;
984 if ( isset( $item['description'] ) && is_string( $item['description'] ) ) {
985 $description = $item['description'];
986 } elseif ( isset( $item['desc'] ) && is_string( $item['desc'] ) ) {
987 $description = $item['desc'];
988 }
989 if ( isset( $item['note_added_time'] ) && is_scalar( $item['note_added_time'] ) ) {
990 $note_added_time = (string) $item['note_added_time'];
991 }
992 $this->local_store->update_metadata( $key, $name, $description, $note_added_time );
993 }
994
995 return new Response(
996 true,
997 array(
998 'message' => esc_html__( 'Configs synced successfully.', 'defender-security' ),
999 'configs' => $this->local_store->get_frontend_configs(),
1000 )
1001 );
1002 }
1003
1004 /**
1005 * Delete config.
1006 *
1007 * @param Request $request The request object containing config key.
1008 *
1009 * @return Response
1010 * @defender_route
1011 */
1012 public function delete_config( Request $request ) {
1013 $data = $request->get_data();
1014 $key = trim( $data['key'] );
1015 $key = $this->resolve_config_key( $key, $this->local_store->get_all() );
1016 if ( '' === $key ) {
1017 return new Response(
1018 false,
1019 array(
1020 'message' => esc_html__( 'Invalid config', 'defender-security' ),
1021 )
1022 );
1023 }
1024
1025 $config = $this->local_store->get_one( $key );
1026 if ( null === $config ) {
1027 $config = get_site_option( $key );
1028 }
1029 if ( isset( $config['is_removable'] ) && false === (bool) $config['is_removable'] ) {
1030 return new Response(
1031 false,
1032 array(
1033 'message' => esc_html__( 'Config can\'t be removed', 'defender-security' ),
1034 )
1035 );
1036 }
1037
1038 if ( $this->local_store->delete_one( $key ) ) {
1039 return new Response(
1040 true,
1041 array(
1042 'message' => esc_html__( 'Config removed successfully.', 'defender-security' ),
1043 'auto_close' => true,
1044 'configs' => $this->local_store->get_frontend_configs(),
1045 )
1046 );
1047 }
1048
1049 if ( 0 === strpos( $key, 'wp_defender_config' ) ) {
1050 delete_site_option( $key );
1051 $this->service->remove_index( $key );
1052 $this->service->clear_keys();
1053 delete_site_transient( Config_Hub_Helper::CONFIGS_TRANSIENT_KEY );
1054
1055 return new Response(
1056 true,
1057 array(
1058 'message' => esc_html__( 'Config removed successfully.', 'defender-security' ),
1059 'auto_close' => true,
1060 'configs' => $this->local_store->get_frontend_configs(),
1061 )
1062 );
1063 }
1064
1065 return new Response(
1066 false,
1067 array(
1068 'message' => esc_html__( 'Invalid config', 'defender-security' ),
1069 )
1070 );
1071 }
1072
1073 /**
1074 * Syncs configs list by keeping only the provided config keys.
1075 *
1076 * This method is intentionally separate from `delete_config` for backward compatibility.
1077 *
1078 * @param Request $request The request object containing the list of configs to keep.
1079 *
1080 * @return Response
1081 * @defender_route
1082 */
1083 public function sync_configs_list( Request $request ): Response {
1084 // Defense in depth: central routing already enforces nonce and private access checks.
1085 if ( ! $this->check_permission() ) {
1086 return new Response(
1087 false,
1088 array(
1089 'message' => esc_html__( 'You shall not pass.', 'defender-security' ),
1090 )
1091 );
1092 }
1093
1094 $data = $request->get_data();
1095 if ( ! isset( $data['configs'] ) || ! is_array( $data['configs'] ) ) {
1096 return new Response(
1097 false,
1098 array(
1099 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
1100 )
1101 );
1102 }
1103
1104 $stored_configs = $this->local_store->get_all();
1105 $keep_keys = array();
1106 foreach ( $data['configs'] as $item ) {
1107 if ( ! is_array( $item ) ) {
1108 return new Response(
1109 false,
1110 array(
1111 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
1112 )
1113 );
1114 }
1115 $key = '';
1116 if ( isset( $item['key'] ) && is_string( $item['key'] ) ) {
1117 $key = trim( $item['key'] );
1118 } elseif ( isset( $item['id'] ) && is_string( $item['id'] ) ) {
1119 $key = trim( $item['id'] );
1120 }
1121
1122 if (
1123 '' === $key ||
1124 0 !== strpos( $key, 'wp_defender_config' ) ||
1125 ! isset( $stored_configs[ $key ] )
1126 ) {
1127 return new Response(
1128 false,
1129 array(
1130 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
1131 )
1132 );
1133 }
1134
1135 $keep_keys[ $key ] = true;
1136 }
1137
1138 $this->local_store->sync_keep_list( array_keys( $keep_keys ) );
1139
1140 return new Response(
1141 true,
1142 array(
1143 'message' => esc_html__( 'Config list synced successfully.', 'defender-security' ),
1144 'auto_close' => true,
1145 'configs' => $this->local_store->get_frontend_configs(),
1146 )
1147 );
1148 }
1149
1150 /**
1151 * Exports strings.
1152 *
1153 * @return array An array of strings.
1154 */
1155 public function export_strings(): array {
1156 return array();
1157 }
1158
1159 /**
1160 * Resolves a config key from an incoming identifier.
1161 *
1162 * Supports direct option keys and numeric Hub IDs.
1163 *
1164 * @param string $incoming_key Incoming key/id.
1165 * @param array|null $stored_configs Optional preloaded configs map.
1166 *
1167 * @return string
1168 */
1169 private function resolve_config_key( string $incoming_key, ?array $stored_configs = null ): string {
1170 $key = trim( $incoming_key );
1171 if ( '' === $key ) {
1172 return '';
1173 }
1174
1175 if ( 0 === strpos( $key, 'wp_defender_config' ) ) {
1176 return $key;
1177 }
1178
1179 $configs = is_array( $stored_configs ) ? $stored_configs : $this->local_store->get_all();
1180 foreach ( $configs as $config_key => $config ) {
1181 if ( ! is_array( $config ) || ! isset( $config['hub_id'] ) ) {
1182 continue;
1183 }
1184 if ( (string) $config['hub_id'] === $key ) {
1185 return (string) $config_key;
1186 }
1187 }
1188
1189 return $key;
1190 }
1191
1192 /**
1193 * Generates an error message for when there is an issue applying some tweaks from the Hardening tab.
1194 *
1195 * @return Response The response object containing the error message and fresh frontend configurations.
1196 */
1197 private function apply_config_recommendations_error_message(): Response {
1198 $message = sprintf(
1199 /* translators: 1: Hardening tab, 2: wp-config.php file, 3: Documentation. */
1200 esc_html__(
1201 'There was an issue with applying some of the tweaks from the %1$s tab because we cannot make changes to your %2$s file. Please see our %3$s to apply the changes manually.',
1202 'defender-security'
1203 ),
1204 '<strong>' . esc_html__( 'Hardening', 'defender-security' ) . '</strong>',
1205 '<strong>' . esc_html__( 'wp-config.php', 'defender-security' ) . '</strong>',
1206 '<a href="' . WP_DEFENDER_DOCS_LINK . '#manually-applying-recommendations" target="_blank">' . esc_html__( 'documentation', 'defender-security' ) . '</a>'
1207 );
1208
1209 return new Response(
1210 false,
1211 array(
1212 'message' => $message,
1213 'configs' => $this->local_store->get_frontend_configs(),
1214 )
1215 );
1216 }
1217
1218 /**
1219 * Prepares a config payload for download.
1220 *
1221 * @param string $key Config option key.
1222 *
1223 * @return array|null
1224 */
1225 private function prepare_config_for_download( string $key ): ?array {
1226 $config = get_site_option( $key );
1227 if ( false === $config || ! is_array( $config ) ) {
1228 return null;
1229 }
1230
1231 $sample = $this->service->gather_data();
1232 foreach ( $sample as $slug => $data ) {
1233 foreach ( $data as $k => $val ) {
1234 if ( ! isset( $config['configs'][ $slug ][ $k ] ) ) {
1235 $config['configs'][ $slug ][ $k ] = null;
1236 }
1237 }
1238 }
1239
1240 return $config;
1241 }
1242
1243 /**
1244 * Creates a unique JSON filename for a zip entry.
1245 *
1246 * @param string $base_name Base file name without extension.
1247 * @param string $key Config option key.
1248 * @param \ZipArchive $zip Active zip archive.
1249 *
1250 * @return string
1251 */
1252 private function make_unique_zip_entry_name( string $base_name, string $key, \ZipArchive $zip ): string {
1253 $base_name = trim( $base_name );
1254 if ( '' === $base_name ) {
1255 $base_name = 'wp-defender-config';
1256 }
1257
1258 $try_name = $base_name . '.json';
1259 if ( false === $zip->locateName( $try_name ) ) {
1260 return $try_name;
1261 }
1262
1263 $key_suffix = sanitize_file_name( str_replace( 'wp_defender_config_', '', $key ) );
1264 if ( '' !== $key_suffix ) {
1265 $try_name = $base_name . '-' . $key_suffix . '.json';
1266 if ( false === $zip->locateName( $try_name ) ) {
1267 return $try_name;
1268 }
1269 }
1270
1271 $index = 2;
1272 while ( true ) {
1273 $try_name = $base_name . '-' . $index . '.json';
1274 if ( false === $zip->locateName( $try_name ) ) {
1275 return $try_name;
1276 }
1277 ++$index;
1278 }
1279 }
1280
1281 /**
1282 * Clear out lines that are older than 30 days.
1283 *
1284 * @return void
1285 */
1286 public function clear_logs(): void {
1287 // since 2.7.0.
1288 $time_limit = apply_filters( 'wpdef_clear_logs_time_limit', MONTH_IN_SECONDS );
1289
1290 if ( is_multisite() ) {
1291 global $wpdb;
1292 $offset = 0;
1293 $limit = 100;
1294 $blogs = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1295 $wpdb->prepare( "SELECT blog_id FROM {$wpdb->blogs} LIMIT %d, %d", $offset, $limit ),
1296 ARRAY_A
1297 );
1298 while ( is_array( $blogs ) && array() !== $blogs ) {
1299 foreach ( $blogs as $blog ) {
1300 switch_to_blog( $blog['blog_id'] );
1301
1302 $this->clear_logs_from_files( $time_limit );
1303
1304 restore_current_blog();
1305 }
1306 $offset += $limit;
1307 $blogs = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1308 $wpdb->prepare( "SELECT blog_id FROM {$wpdb->blogs} LIMIT %d, %d", $offset, $limit ),
1309 ARRAY_A
1310 );
1311 }
1312 } else {
1313 $this->clear_logs_from_files( $time_limit );
1314 }
1315 }
1316
1317 /**
1318 * Clear log files older than the specified time.
1319 *
1320 * @param int $time_limit The time limit in seconds.
1321 *
1322 * @return void
1323 * @since 2.7.0
1324 */
1325 public function clear_logs_from_files( int $time_limit = MONTH_IN_SECONDS ) {
1326 global $wp_filesystem;
1327 // Initialize the WP filesystem, no more using 'file-put-contents' function.
1328 if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) {
1329 require_once ABSPATH . '/wp-admin/includes/file.php';
1330 WP_Filesystem();
1331 }
1332 $now = wp_date( 'c' );
1333 $files = array( wd_internal_log() );
1334
1335 foreach ( $files as $file_name ) {
1336 $file_path = $this->get_log_path( $file_name );
1337
1338 if ( ! file_exists( $file_path ) ) {
1339 return;
1340 }
1341
1342 $content = file( $file_path );
1343 $size_of_content = is_array( $content ) || $content instanceof Countable ? count( $content ) : 0;
1344
1345 foreach ( $content as $index => $line ) {
1346 // If the line does not start with '[' (it's probably not a new entry).
1347 $first_char = substr( $line, 0, 1 );
1348
1349 if ( '[' !== $first_char ) {
1350 // Delete.
1351 unset( $content[ $index ] );
1352 }
1353
1354 /**
1355 * Get the date from entry. Items can be an array it two cases - if there's a valid date, or if the line
1356 * contained something like [header] in the start. Cannot make assumptions just on the fact it's an array.
1357 */
1358 preg_match( '/\[(.*)\]/', $line, $items );
1359
1360 // If, for some reason, can't get the date, or it's not the size of an ISO 8601 date.
1361 if ( ! isset( $items[1] ) || 25 !== strlen( $items[1] ) ) {
1362 // Delete.
1363 unset( $content[ $index ] );
1364 } else {
1365 // It looks like it's a valid date string, compare with today.
1366 $time_diff = strtotime( $now ) - strtotime( $items[1] );
1367
1368 // We don't need to continue on, because if this entry is not older than specific time, the next one will not be as well.
1369 if ( $time_diff < $time_limit ) {
1370 break;
1371 }
1372
1373 unset( $content[ $index ] );
1374 }
1375 }
1376
1377 // Nothing changed - do nothing.
1378 if ( ( is_array( $content ) || $content instanceof Countable ? count( $content ) : 0 ) === $size_of_content ) {
1379 return;
1380 }
1381
1382 // Glue back together and write back to file.
1383 $content = implode( '', $content );
1384
1385 $wp_filesystem->put_contents( $file_path, $content );
1386 }
1387 }
1388
1389 /**
1390 * Track the data if there are settings changes.
1391 *
1392 * @param array $old_settings Old settings.
1393 * @param array $new_settings New settings.
1394 *
1395 * @return void
1396 * @since 4.2.0
1397 */
1398 public function intercept_settings_update( $old_settings, $new_settings ) {
1399 $from = $this->get_triggered_location();
1400 if (
1401 '' !== $from
1402 && isset( $new_settings['usage_tracking'], $old_settings['usage_tracking'] )
1403 && $new_settings['usage_tracking'] !== $old_settings['usage_tracking']
1404 ) {
1405 $this->track_opt_toggle( (bool) $new_settings['usage_tracking'], $from );
1406 }
1407 }
1408 }
1409