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

1,429 lines 39.4 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/core-ui.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 if ( ! $this->service->verify_config_data( $importer ) || ! is_array( $importer['configs'] ) ) {
422 return false;
423 }
424
425 $sample = $this->service->gather_data();
426 foreach ( $importer['configs'] as $slug => $module ) {
427 // Ignore whole modules unavailable on this site, such as Pro-only modules in Free builds.
428 if ( ! isset( $sample[ $slug ] ) ) {
429 continue;
430 }
431
432 if (
433 ! is_array( $module )
434 || ! is_array( $sample[ $slug ] )
435 || array() !== array_diff_key( $module, $sample[ $slug ] )
436 ) {
437 return false;
438 }
439 }
440
441 return true;
442 }
443
444 /**
445 * Import config.
446 *
447 * @return Response
448 * @defender_route
449 */
450 public function import_config(): Response {
451 global $wp_filesystem;
452 // Initialize the WP filesystem, no more using 'file-put-contents' function.
453 if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) {
454 require_once ABSPATH . '/wp-admin/includes/file.php';
455 WP_Filesystem();
456 }
457 $file = defender_get_data_from_request( 'file', 'f' );
458 $tmp = $file['tmp_name'];
459 $content = $wp_filesystem->get_contents( $tmp );
460 $importer = json_decode( $content, true );
461 if ( ! is_array( $importer ) ) {
462 return new Response(
463 false,
464 array(
465 'message' => esc_html__( 'The file is corrupted.', 'defender-security' ),
466 )
467 );
468 }
469
470 if ( ! $this->validate_importer( $importer ) ) {
471 return new Response(
472 false,
473 array(
474 'message' => esc_html__(
475 'An error occurred while importing the file. Please check your file or upload another file.',
476 'defender-security'
477 ),
478 )
479 );
480 }
481
482 // Do not use wp_strip_all_tags() to prevent XSS attack.
483 $name = sanitize_text_field( $importer['name'] );
484 $this->local_store->add_imported( $importer );
485
486 // Detect Pro-only modules that were skipped during validation
487 // to inform Free users that some settings were not imported.
488 $response_data = array(
489 'message' => sprintf(
490 /* translators: %s: Config name. */
491 esc_html__(
492 '%s config has been uploaded successfully – you can now apply it to this site.',
493 'defender-security'
494 ),
495 '<strong>' . $name . '</strong>'
496 ),
497 'configs' => $this->local_store->get_frontend_configs(),
498 );
499
500 if ( ! $this->wpmudev->is_pro() ) {
501 $sample = $this->service->gather_data();
502 $pro_modules = array();
503
504 foreach ( $importer['configs'] as $slug => $module ) {
505 // Modules present in the imported config but NOT in Free's sample data are Pro-only.
506 if ( ! isset( $sample[ $slug ] ) ) {
507 $pro_modules[] = $slug;
508 }
509 }
510
511 if ( array() !== $pro_modules ) {
512 $module_names = array_map(
513 function ( $slug ) {
514 $names = array(
515 'audit' => esc_html__( 'Audit Logs', 'defender-security' ),
516 'waf' => esc_html__( 'Web Application Firewall (WAF)', 'defender-security' ),
517 'blocklist_monitor' => esc_html__( 'Blocklist Monitor', 'defender-security' ),
518 'pwned_passwords' => esc_html__( 'Pwned Passwords', 'defender-security' ),
519 'force_strong_password' => esc_html__( 'Strong Passwords', 'defender-security' ),
520 'session_protection' => esc_html__( 'Session Protection', 'defender-security' ),
521 'two_factor' => esc_html__( 'Two-Factor Authentication', 'defender-security' ),
522 'mask_login' => esc_html__( 'Hide Login URL', 'defender-security' ),
523 'security_headers' => esc_html__( 'Security Policies', 'defender-security' ),
524 );
525
526 return $names[ $slug ] ?? $slug;
527 },
528 $pro_modules
529 );
530
531 $response_data['message'] .= ' ' . sprintf(
532 /* translators: %s: Comma-separated list of Pro-only feature names. */
533 esc_html__(
534 'This config included features not available in the Free version: %s. Only the available settings were imported.',
535 'defender-security'
536 ),
537 implode( ', ', $module_names )
538 );
539 }
540 }
541
542 return new Response(
543 true,
544 $response_data
545 );
546 }
547
548 /**
549 * Create config.
550 *
551 * @param Request $request The request object containing new config data.
552 *
553 * @return Response
554 * @defender_route
555 */
556 public function new_config( Request $request ): Response {
557 $data = $request->get_data();
558 $name = trim( $data['name'] );
559 if ( '' === $name ) {
560 return new Response(
561 false,
562 array(
563 'message' => esc_html__( 'Invalid config name', 'defender-security' ),
564 )
565 );
566 }
567 $name = sanitize_text_field( $name );
568 $desc = '';
569 if ( isset( $data['desc'] ) && is_string( $data['desc'] ) && '' !== trim( $data['desc'] ) ) {
570 $desc = wp_kses_post( $data['desc'] );
571 } elseif ( isset( $data['description'] ) && is_string( $data['description'] ) && '' !== trim( $data['description'] ) ) {
572 $desc = wp_kses_post( $data['description'] );
573 }
574 $note_added_time = isset( $data['note_added_time'] ) && is_scalar( $data['note_added_time'] )
575 ? sanitize_text_field( (string) $data['note_added_time'] )
576 : '';
577 $this->local_store->create_from_current( $name, $desc, $note_added_time );
578
579 return new Response(
580 true,
581 array(
582 'message' => sprintf(
583 /* translators: %s: Config name. */
584 esc_html__( '%s config saved successfully.', 'defender-security' ),
585 '<strong>' . $name . '</strong>'
586 ),
587 'configs' => $this->local_store->get_frontend_configs(),
588 )
589 );
590 }
591
592 /**
593 * Download config
594 *
595 * @return Response|void
596 * @defender_route
597 */
598 public function download_config() {
599 $key = defender_get_data_from_request( 'key', 'g' );
600 if ( ! is_string( $key ) || '' === trim( $key ) ) {
601 return new Response(
602 false,
603 array(
604 'message' => esc_html__( 'Invalid config', 'defender-security' ),
605 )
606 );
607 }
608
609 $config = $this->local_store->get_full_for_key( $key );
610 if ( null === $config ) {
611 $config = $this->prepare_config_for_download( $key );
612 }
613 if ( null === $config ) {
614 return new Response(
615 false,
616 array(
617 'message' => esc_html__( 'Invalid config', 'defender-security' ),
618 )
619 );
620 }
621
622 $filename = 'wp-defender-config-' . sanitize_file_name( $config['name'] ) . '.json';
623 header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
624 echo wp_json_encode( $config, JSON_PRETTY_PRINT );
625 exit();
626 }
627
628 /**
629 * Download multiple configs as a zip file.
630 *
631 * @return Response|void
632 * @defender_route
633 */
634 public function download_configs_zip() {
635 $keys_raw = defender_get_data_from_request( 'keys', 'g' );
636 if ( ! is_string( $keys_raw ) || '' === trim( $keys_raw ) ) {
637 return new Response(
638 false,
639 array(
640 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
641 )
642 );
643 }
644
645 $decoded = json_decode( wp_unslash( $keys_raw ), true );
646 $keys = is_array( $decoded ) ? $decoded : explode( ',', $keys_raw );
647 $keys = array_values(
648 array_filter(
649 array_map(
650 static function ( $key ) {
651 return is_string( $key ) ? trim( $key ) : '';
652 },
653 $keys
654 ),
655 'boolval'
656 )
657 );
658
659 if ( array() === $keys ) {
660 return new Response(
661 false,
662 array(
663 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
664 )
665 );
666 }
667
668 if ( ! class_exists( 'ZipArchive' ) ) {
669 return new Response(
670 false,
671 array(
672 'message' => esc_html__( 'Zip archive is not supported on this server.', 'defender-security' ),
673 )
674 );
675 }
676
677 $tmp_zip_path = wp_tempnam( 'wp-defender-configs-' . time() . '.zip' );
678 if ( false === $tmp_zip_path ) {
679 return new Response(
680 false,
681 array(
682 'message' => esc_html__( 'Could not prepare zip file.', 'defender-security' ),
683 )
684 );
685 }
686
687 $cleanup_tmp_zip = static function () use ( $tmp_zip_path ) {
688 if ( file_exists( $tmp_zip_path ) ) {
689 wp_delete_file( $tmp_zip_path );
690 }
691 };
692
693 $zip = new \ZipArchive();
694 if ( true !== $zip->open( $tmp_zip_path, \ZipArchive::OVERWRITE ) ) {
695 $cleanup_tmp_zip();
696
697 return new Response(
698 false,
699 array(
700 'message' => esc_html__( 'Could not create zip file.', 'defender-security' ),
701 )
702 );
703 }
704
705 $has_entries = false;
706
707 foreach ( $keys as $key ) {
708 $config = $this->local_store->get_full_for_key( $key );
709 if ( null === $config ) {
710 $config = $this->prepare_config_for_download( $key );
711 }
712 if ( null === $config ) {
713 continue;
714 }
715
716 $file_name = $this->make_unique_zip_entry_name(
717 'wp-defender-config-' . sanitize_file_name( $config['name'] ),
718 $key,
719 $zip
720 );
721 if ( false === $zip->addFromString( $file_name, wp_json_encode( $config, JSON_PRETTY_PRINT ) ) ) {
722 $zip->close();
723 $cleanup_tmp_zip();
724
725 return new Response(
726 false,
727 array(
728 'message' => esc_html__( 'Could not prepare zip file.', 'defender-security' ),
729 )
730 );
731 }
732
733 $has_entries = true;
734 }
735
736 $zip->close();
737
738 if ( ! $has_entries ) {
739 $cleanup_tmp_zip();
740
741 return new Response(
742 false,
743 array(
744 'message' => esc_html__( 'No valid configs found to export.', 'defender-security' ),
745 )
746 );
747 }
748
749 $download_name = 'wp-defender-configs-' . gmdate( 'Ymd-His' ) . '.zip';
750 header( 'Content-Type: application/zip' );
751 header( 'Content-Disposition: attachment; filename="' . $download_name . '"' );
752 header( 'Content-Length: ' . filesize( $tmp_zip_path ) );
753 readfile( $tmp_zip_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
754 $cleanup_tmp_zip();
755 exit();
756 }
757
758 /**
759 * Apply config.
760 *
761 * @param Request $request The request object containing new config data.
762 *
763 * @return Response
764 * @defender_route
765 */
766 public function apply_config( Request $request ) {
767 $data = $request->get_data();
768 $key = trim( $data['key'] );
769 if ( '' === $key ) {
770 return new Response(
771 false,
772 array(
773 'message' => esc_html__( 'Invalid config', 'defender-security' ),
774 )
775 );
776 }
777
778 $config = $this->local_store->get_full_for_key( $key );
779 if ( null === $config ) {
780 $config = get_site_option( $key );
781 }
782 if ( false === $config || null === $config ) {
783 return new Response(
784 false,
785 array(
786 'message' => esc_html__( 'Invalid config', 'defender-security' ),
787 )
788 );
789 }
790 // Return error message or bool value for auth action.
791 $restore_result = $this->service->restore_data( $config['configs'], 'plugin' );
792 if ( is_string( $restore_result ) ) {
793 return $this->apply_config_recommendations_error_message();
794 }
795
796 $this->local_store->apply( $key );
797 $this->service->make_config_active( $key );
798 // Track.
799 $this->track_feature(
800 'def_config_applied',
801 array(
802 // The check is based on the fact that the Default config cannot be deleted.
803 'Config Type' => isset( $config['is_removable'] ) && false === $config['is_removable'] ? 'Default' : 'Custom',
804 )
805 );
806
807 $message = sprintf(
808 /* translators: %s: Config name. */
809 esc_html__(
810 '%s config has been applied successfully.',
811 'defender-security'
812 ),
813 '<strong>' . $config['name'] . '</strong>'
814 );
815 $return = array();
816 if ( $restore_result ) {
817 $login_url = wp_login_url();
818 $settings_mask_login = new \WP_Defender\Model\Setting\Mask_Login();
819 if ( $settings_mask_login->is_active() ) {
820 $login_url = $settings_mask_login->get_new_login_url();
821 }
822 $message .= '<br/>' . sprintf(
823 /* translators: %s: Login link. */
824 esc_html__(
825 'Due to currently applied security recommendations, you will now need to %s.',
826 'defender-security'
827 ),
828 '<a href="' . $login_url . '"><strong>' . esc_html__( 're-login', 'defender-security' ) . '</strong></a>'
829 );
830 $message .= '<br/>';
831 $message .= esc_html__( 'This will auto reload now.', 'defender-security' );
832
833 $return['reload'] = 3;
834 $redirect = rawurlencode( network_admin_url( 'admin.php?page=wdf-setting&view=configs' ) );
835 if ( isset( $data['screen'] ) && 'dashboard' === $data['screen'] ) {
836 $redirect = rawurlencode( network_admin_url( 'admin.php?page=wp-defender' ) );
837 }
838 $return['redirect'] = add_query_arg(
839 'redirect_to',
840 $redirect,
841 $login_url
842 );
843 $return['interval'] = 2;
844 }
845
846 $return['message'] = $message;
847 $return['auto_close'] = true;
848 $return['configs'] = $this->local_store->get_frontend_configs();
849
850 return new Response( true, $return );
851 }
852
853 /**
854 * Update config.
855 *
856 * @param Request $request The request object containing new config data.
857 *
858 * @return Response
859 * @defender_route
860 */
861 public function update_config( Request $request ) {
862 $data = $request->get_data();
863 $key = trim( $data['key'] );
864 $key = $this->resolve_config_key( $key );
865 $name = trim( $data['name'] );
866 $description = isset( $data['desc'] ) ? trim( (string) $data['desc'] ) : '';
867 if ( '' === $description && isset( $data['description'] ) ) {
868 $description = trim( (string) $data['description'] );
869 }
870 $note_added_time = isset( $data['note_added_time'] ) && is_scalar( $data['note_added_time'] )
871 ? sanitize_text_field( (string) $data['note_added_time'] )
872 : null;
873 if ( '' === $name || '' === $key ) {
874 return new Response(
875 false,
876 array(
877 'message' => esc_html__( 'Invalid config', 'defender-security' ),
878 )
879 );
880 }
881
882 $config = $this->local_store->get_one( $key );
883 if ( null === $config ) {
884 $config = get_site_option( $key );
885 }
886 if ( false === $config || null === $config ) {
887 return new Response(
888 false,
889 array(
890 'message' => esc_html__( 'Invalid config', 'defender-security' ),
891 )
892 );
893 }
894
895 $option_updated = $this->local_store->update_metadata( $key, $name, $description, $note_added_time );
896 if ( ! $option_updated ) {
897 $old_config = $config;
898 $config['name'] = sanitize_text_field( $name );
899 $config['description'] = sanitize_textarea_field( $description );
900 if ( null !== $note_added_time ) {
901 $config['note_added_time'] = $note_added_time;
902 }
903 $option_updated = update_site_option( $key, $config );
904 }
905
906 if ( $option_updated ) {
907 return new Response(
908 true,
909 array(
910 'message' => sprintf(
911 /* translators: %s: Config name. */
912 esc_html__( '%s config saved successfully.', 'defender-security' ),
913 '<strong>' . $name . '</strong>'
914 ),
915 'auto_close' => true,
916 'configs' => $this->local_store->get_frontend_configs(),
917 )
918 );
919 } else {
920 return new Response(
921 false,
922 array(
923 'message' => esc_html__(
924 'An error occurred while saving your config. Please try it again.',
925 'defender-security'
926 ),
927 )
928 );
929 }
930 }
931
932 /**
933 * Sync config metadata list.
934 *
935 * This method updates name/description/note timestamp for provided configs
936 * without changing config payload values.
937 *
938 * @param Request $request The request object containing configs metadata list.
939 *
940 * @return Response
941 * @defender_route
942 */
943 public function sync_configs_metadata( Request $request ): Response {
944 // Defense in depth: central routing already validates nonce and access.
945 if ( ! $this->check_permission() ) {
946 return new Response(
947 false,
948 array(
949 'message' => esc_html__( 'You shall not pass.', 'defender-security' ),
950 )
951 );
952 }
953
954 $data = $request->get_data();
955 if ( ! isset( $data['configs'] ) || ! is_array( $data['configs'] ) ) {
956 return new Response(
957 false,
958 array(
959 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
960 )
961 );
962 }
963
964 $stored_configs = $this->local_store->get_all();
965 foreach ( $data['configs'] as $item ) {
966 if ( ! is_array( $item ) ) {
967 return new Response(
968 false,
969 array(
970 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
971 )
972 );
973 }
974
975 $key = '';
976 if ( isset( $item['key'] ) && is_string( $item['key'] ) ) {
977 $key = trim( $item['key'] );
978 } elseif ( isset( $item['id'] ) && is_string( $item['id'] ) ) {
979 $key = trim( $item['id'] );
980 }
981 $key = $this->resolve_config_key( $key, $stored_configs );
982
983 if (
984 '' === $key ||
985 0 !== strpos( $key, 'wp_defender_config' ) ||
986 ! isset( $stored_configs[ $key ] )
987 ) {
988 return new Response(
989 false,
990 array(
991 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
992 )
993 );
994 }
995
996 $config = $this->local_store->get_one( $key );
997 if ( null === $config ) {
998 continue;
999 }
1000
1001 $name = isset( $item['name'] ) ? (string) $item['name'] : null;
1002 $description = null;
1003 $note_added_time = null;
1004 if ( isset( $item['description'] ) && is_string( $item['description'] ) ) {
1005 $description = $item['description'];
1006 } elseif ( isset( $item['desc'] ) && is_string( $item['desc'] ) ) {
1007 $description = $item['desc'];
1008 }
1009 if ( isset( $item['note_added_time'] ) && is_scalar( $item['note_added_time'] ) ) {
1010 $note_added_time = (string) $item['note_added_time'];
1011 }
1012 $this->local_store->update_metadata( $key, $name, $description, $note_added_time );
1013 }
1014
1015 return new Response(
1016 true,
1017 array(
1018 'message' => esc_html__( 'Configs synced successfully.', 'defender-security' ),
1019 'configs' => $this->local_store->get_frontend_configs(),
1020 )
1021 );
1022 }
1023
1024 /**
1025 * Delete config.
1026 *
1027 * @param Request $request The request object containing config key.
1028 *
1029 * @return Response
1030 * @defender_route
1031 */
1032 public function delete_config( Request $request ) {
1033 $data = $request->get_data();
1034 $key = trim( $data['key'] );
1035 $key = $this->resolve_config_key( $key, $this->local_store->get_all() );
1036 if ( '' === $key ) {
1037 return new Response(
1038 false,
1039 array(
1040 'message' => esc_html__( 'Invalid config', 'defender-security' ),
1041 )
1042 );
1043 }
1044
1045 $config = $this->local_store->get_one( $key );
1046 if ( null === $config ) {
1047 $config = get_site_option( $key );
1048 }
1049 if ( isset( $config['is_removable'] ) && false === (bool) $config['is_removable'] ) {
1050 return new Response(
1051 false,
1052 array(
1053 'message' => esc_html__( 'Config can\'t be removed', 'defender-security' ),
1054 )
1055 );
1056 }
1057
1058 if ( $this->local_store->delete_one( $key ) ) {
1059 return new Response(
1060 true,
1061 array(
1062 'message' => esc_html__( 'Config removed successfully.', 'defender-security' ),
1063 'auto_close' => true,
1064 'configs' => $this->local_store->get_frontend_configs(),
1065 )
1066 );
1067 }
1068
1069 if ( 0 === strpos( $key, 'wp_defender_config' ) ) {
1070 delete_site_option( $key );
1071 $this->service->remove_index( $key );
1072 $this->service->clear_keys();
1073 delete_site_transient( Config_Hub_Helper::CONFIGS_TRANSIENT_KEY );
1074
1075 return new Response(
1076 true,
1077 array(
1078 'message' => esc_html__( 'Config removed successfully.', 'defender-security' ),
1079 'auto_close' => true,
1080 'configs' => $this->local_store->get_frontend_configs(),
1081 )
1082 );
1083 }
1084
1085 return new Response(
1086 false,
1087 array(
1088 'message' => esc_html__( 'Invalid config', 'defender-security' ),
1089 )
1090 );
1091 }
1092
1093 /**
1094 * Syncs configs list by keeping only the provided config keys.
1095 *
1096 * This method is intentionally separate from `delete_config` for backward compatibility.
1097 *
1098 * @param Request $request The request object containing the list of configs to keep.
1099 *
1100 * @return Response
1101 * @defender_route
1102 */
1103 public function sync_configs_list( Request $request ): Response {
1104 // Defense in depth: central routing already enforces nonce and private access checks.
1105 if ( ! $this->check_permission() ) {
1106 return new Response(
1107 false,
1108 array(
1109 'message' => esc_html__( 'You shall not pass.', 'defender-security' ),
1110 )
1111 );
1112 }
1113
1114 $data = $request->get_data();
1115 if ( ! isset( $data['configs'] ) || ! is_array( $data['configs'] ) ) {
1116 return new Response(
1117 false,
1118 array(
1119 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
1120 )
1121 );
1122 }
1123
1124 $stored_configs = $this->local_store->get_all();
1125 $keep_keys = array();
1126 foreach ( $data['configs'] as $item ) {
1127 if ( ! is_array( $item ) ) {
1128 return new Response(
1129 false,
1130 array(
1131 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
1132 )
1133 );
1134 }
1135 $key = '';
1136 if ( isset( $item['key'] ) && is_string( $item['key'] ) ) {
1137 $key = trim( $item['key'] );
1138 } elseif ( isset( $item['id'] ) && is_string( $item['id'] ) ) {
1139 $key = trim( $item['id'] );
1140 }
1141
1142 if (
1143 '' === $key ||
1144 0 !== strpos( $key, 'wp_defender_config' ) ||
1145 ! isset( $stored_configs[ $key ] )
1146 ) {
1147 return new Response(
1148 false,
1149 array(
1150 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
1151 )
1152 );
1153 }
1154
1155 $keep_keys[ $key ] = true;
1156 }
1157
1158 $this->local_store->sync_keep_list( array_keys( $keep_keys ) );
1159
1160 return new Response(
1161 true,
1162 array(
1163 'message' => esc_html__( 'Config list synced successfully.', 'defender-security' ),
1164 'auto_close' => true,
1165 'configs' => $this->local_store->get_frontend_configs(),
1166 )
1167 );
1168 }
1169
1170 /**
1171 * Exports strings.
1172 *
1173 * @return array An array of strings.
1174 */
1175 public function export_strings(): array {
1176 return array();
1177 }
1178
1179 /**
1180 * Resolves a config key from an incoming identifier.
1181 *
1182 * Supports direct option keys and numeric Hub IDs.
1183 *
1184 * @param string $incoming_key Incoming key/id.
1185 * @param array|null $stored_configs Optional preloaded configs map.
1186 *
1187 * @return string
1188 */
1189 private function resolve_config_key( string $incoming_key, ?array $stored_configs = null ): string {
1190 $key = trim( $incoming_key );
1191 if ( '' === $key ) {
1192 return '';
1193 }
1194
1195 if ( 0 === strpos( $key, 'wp_defender_config' ) ) {
1196 return $key;
1197 }
1198
1199 $configs = is_array( $stored_configs ) ? $stored_configs : $this->local_store->get_all();
1200 foreach ( $configs as $config_key => $config ) {
1201 if ( ! is_array( $config ) || ! isset( $config['hub_id'] ) ) {
1202 continue;
1203 }
1204 if ( (string) $config['hub_id'] === $key ) {
1205 return (string) $config_key;
1206 }
1207 }
1208
1209 return $key;
1210 }
1211
1212 /**
1213 * Generates an error message for when there is an issue applying some tweaks from the Hardening tab.
1214 *
1215 * @return Response The response object containing the error message and fresh frontend configurations.
1216 */
1217 private function apply_config_recommendations_error_message(): Response {
1218 $message = sprintf(
1219 /* translators: 1: Hardening tab, 2: wp-config.php file, 3: Documentation. */
1220 esc_html__(
1221 '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.',
1222 'defender-security'
1223 ),
1224 '<strong>' . esc_html__( 'Hardening', 'defender-security' ) . '</strong>',
1225 '<strong>' . esc_html__( 'wp-config.php', 'defender-security' ) . '</strong>',
1226 '<a href="' . WP_DEFENDER_DOCS_LINK . '#manually-applying-recommendations" target="_blank">' . esc_html__( 'documentation', 'defender-security' ) . '</a>'
1227 );
1228
1229 return new Response(
1230 false,
1231 array(
1232 'message' => $message,
1233 'configs' => $this->local_store->get_frontend_configs(),
1234 )
1235 );
1236 }
1237
1238 /**
1239 * Prepares a config payload for download.
1240 *
1241 * @param string $key Config option key.
1242 *
1243 * @return array|null
1244 */
1245 private function prepare_config_for_download( string $key ): ?array {
1246 $config = get_site_option( $key );
1247 if ( false === $config || ! is_array( $config ) ) {
1248 return null;
1249 }
1250
1251 $sample = $this->service->gather_data();
1252 foreach ( $sample as $slug => $data ) {
1253 foreach ( $data as $k => $val ) {
1254 if ( ! isset( $config['configs'][ $slug ][ $k ] ) ) {
1255 $config['configs'][ $slug ][ $k ] = null;
1256 }
1257 }
1258 }
1259
1260 return $config;
1261 }
1262
1263 /**
1264 * Creates a unique JSON filename for a zip entry.
1265 *
1266 * @param string $base_name Base file name without extension.
1267 * @param string $key Config option key.
1268 * @param \ZipArchive $zip Active zip archive.
1269 *
1270 * @return string
1271 */
1272 private function make_unique_zip_entry_name( string $base_name, string $key, \ZipArchive $zip ): string {
1273 $base_name = trim( $base_name );
1274 if ( '' === $base_name ) {
1275 $base_name = 'wp-defender-config';
1276 }
1277
1278 $try_name = $base_name . '.json';
1279 if ( false === $zip->locateName( $try_name ) ) {
1280 return $try_name;
1281 }
1282
1283 $key_suffix = sanitize_file_name( str_replace( 'wp_defender_config_', '', $key ) );
1284 if ( '' !== $key_suffix ) {
1285 $try_name = $base_name . '-' . $key_suffix . '.json';
1286 if ( false === $zip->locateName( $try_name ) ) {
1287 return $try_name;
1288 }
1289 }
1290
1291 $index = 2;
1292 while ( true ) {
1293 $try_name = $base_name . '-' . $index . '.json';
1294 if ( false === $zip->locateName( $try_name ) ) {
1295 return $try_name;
1296 }
1297 ++$index;
1298 }
1299 }
1300
1301 /**
1302 * Clear out lines that are older than 30 days.
1303 *
1304 * @return void
1305 */
1306 public function clear_logs(): void {
1307 // since 2.7.0.
1308 $time_limit = apply_filters( 'wpdef_clear_logs_time_limit', MONTH_IN_SECONDS );
1309
1310 if ( is_multisite() ) {
1311 global $wpdb;
1312 $offset = 0;
1313 $limit = 100;
1314 $blogs = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1315 $wpdb->prepare( "SELECT blog_id FROM {$wpdb->blogs} LIMIT %d, %d", $offset, $limit ),
1316 ARRAY_A
1317 );
1318 while ( is_array( $blogs ) && array() !== $blogs ) {
1319 foreach ( $blogs as $blog ) {
1320 switch_to_blog( $blog['blog_id'] );
1321
1322 $this->clear_logs_from_files( $time_limit );
1323
1324 restore_current_blog();
1325 }
1326 $offset += $limit;
1327 $blogs = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1328 $wpdb->prepare( "SELECT blog_id FROM {$wpdb->blogs} LIMIT %d, %d", $offset, $limit ),
1329 ARRAY_A
1330 );
1331 }
1332 } else {
1333 $this->clear_logs_from_files( $time_limit );
1334 }
1335 }
1336
1337 /**
1338 * Clear log files older than the specified time.
1339 *
1340 * @param int $time_limit The time limit in seconds.
1341 *
1342 * @return void
1343 * @since 2.7.0
1344 */
1345 public function clear_logs_from_files( int $time_limit = MONTH_IN_SECONDS ) {
1346 global $wp_filesystem;
1347 // Initialize the WP filesystem, no more using 'file-put-contents' function.
1348 if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) {
1349 require_once ABSPATH . '/wp-admin/includes/file.php';
1350 WP_Filesystem();
1351 }
1352 $now = wp_date( 'c' );
1353 $files = array( wd_internal_log() );
1354
1355 foreach ( $files as $file_name ) {
1356 $file_path = $this->get_log_path( $file_name );
1357
1358 if ( ! file_exists( $file_path ) ) {
1359 return;
1360 }
1361
1362 $content = file( $file_path );
1363 $size_of_content = is_array( $content ) || $content instanceof Countable ? count( $content ) : 0;
1364
1365 foreach ( $content as $index => $line ) {
1366 // If the line does not start with '[' (it's probably not a new entry).
1367 $first_char = substr( $line, 0, 1 );
1368
1369 if ( '[' !== $first_char ) {
1370 // Delete.
1371 unset( $content[ $index ] );
1372 }
1373
1374 /**
1375 * Get the date from entry. Items can be an array it two cases - if there's a valid date, or if the line
1376 * contained something like [header] in the start. Cannot make assumptions just on the fact it's an array.
1377 */
1378 preg_match( '/\[(.*)\]/', $line, $items );
1379
1380 // If, for some reason, can't get the date, or it's not the size of an ISO 8601 date.
1381 if ( ! isset( $items[1] ) || 25 !== strlen( $items[1] ) ) {
1382 // Delete.
1383 unset( $content[ $index ] );
1384 } else {
1385 // It looks like it's a valid date string, compare with today.
1386 $time_diff = strtotime( $now ) - strtotime( $items[1] );
1387
1388 // 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.
1389 if ( $time_diff < $time_limit ) {
1390 break;
1391 }
1392
1393 unset( $content[ $index ] );
1394 }
1395 }
1396
1397 // Nothing changed - do nothing.
1398 if ( ( is_array( $content ) || $content instanceof Countable ? count( $content ) : 0 ) === $size_of_content ) {
1399 return;
1400 }
1401
1402 // Glue back together and write back to file.
1403 $content = implode( '', $content );
1404
1405 $wp_filesystem->put_contents( $file_path, $content );
1406 }
1407 }
1408
1409 /**
1410 * Track the data if there are settings changes.
1411 *
1412 * @param array $old_settings Old settings.
1413 * @param array $new_settings New settings.
1414 *
1415 * @return void
1416 * @since 4.2.0
1417 */
1418 public function intercept_settings_update( $old_settings, $new_settings ) {
1419 $from = $this->get_triggered_location();
1420 if (
1421 '' !== $from
1422 && isset( $new_settings['usage_tracking'], $old_settings['usage_tracking'] )
1423 && $new_settings['usage_tracking'] !== $old_settings['usage_tracking']
1424 ) {
1425 $this->track_opt_toggle( (bool) $new_settings['usage_tracking'], $from );
1426 }
1427 }
1428 }
1429