PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 6.1.0
Defender Security – Malware Scanner, Login Security & Firewall v6.1.0
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.1.0, at src/controller/class-main-setting.php

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