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
← All changes | src/controller/class-main-setting.php +639 -184 5.8.0 → 6.3.0 View file →
@@ -11,15 +11,19 @@
11 11 use WP_Defender\Event;
12 12 use Calotes\Component\Request;
13 13 use Calotes\Component\Response;
14 14 use WP_Defender\Behavior\WPMUDEV;
15 +use WP_Defender\Controller\Notification;
15 16 use WP_Defender\Component\Backup_Settings;
16 17 use WP_Defender\Component\Config\Config_Adapter;
18 +use WP_Defender\Component\Config\Local_Config_Store;
17 19 use WP_Defender\Component\Network_Cron_Manager;
18 20 use WP_Defender\Traits\Defender_Dashboard_Client;
19 21 use WP_Defender\Component\Config\Config_Hub_Helper;
20 22 use WP_Defender\Model\Setting\Main_Setting as Model_Main_Setting;
21 23 use WP_Filesystem_Base;
24 +use WP_Defender\Controller\Security_Headers;
25 +use WP_Defender\Controller\Firewall;
22 26
23 27 /**
24 28 * Methods for handling main settings.
25 29 */
@@ -54,8 +58,15 @@
54 58 */
55 59 protected $wpmudev;
56 60
57 61 /**
62 + * Local configs store.
63 + *
64 + * @var Local_Config_Store
65 + */
66 + protected $local_store;
67 +
68 + /**
58 69 * The intention/nonce action of the current request.
59 70 *
60 71 * @since 4.0.0
61 72 * @var string
@@ -73,11 +84,12 @@
73 84 $this->parent_slug
74 85 );
75 86
76 87 // Internal cache.
77 - $this->model = new Model_Main_Setting();
78 - $this->service = wd_di()->get( Backup_Settings::class );
79 - $this->wpmudev = wd_di()->get( WPMUDEV::class );
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 );
80 92 add_action( 'defender_enqueue_assets', array( $this, 'enqueue_assets' ) );
81 93 $this->register_routes();
82 94
83 95 /**
@@ -92,8 +104,18 @@
92 104 DAY_IN_SECONDS,
93 105 time() + HOUR_IN_SECONDS
94 106 );
95 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 + }
96 118 }
97 119
98 120 /**
99 121 * Check actual config data.
@@ -127,10 +149,73 @@
127 149 public function enqueue_assets() {
128 150 if ( ! $this->is_page_active() ) {
129 151 return;
130 152 }
131 - wp_localize_script( 'def-settings', 'settings', $this->data_frontend() );
132 - wp_enqueue_script( 'def-settings' );
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 +
133 218 $this->enqueue_main_assets();
134 219 }
135 220
136 221 /**
@@ -183,10 +268,12 @@
183 268 * @return Response
184 269 * @defender_route
185 270 */
186 271 public function reset_settings(): Response {
187 - wd_di()->get( Advanced_Tools::class )->remove_settings();
188 - wd_di()->get( Audit_Logging::class )->remove_settings();
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();
189 276 wd_di()->get( Dashboard::class )->remove_settings();
190 277 wd_di()->get( Security_Tweaks::class )->remove_settings();
191 278 wd_di()->get( Scan::class )->remove_settings();
192 279 // Parent and submodules.
@@ -195,12 +282,19 @@
195 282 wd_di()->get( Mask_Login::class )->remove_settings();
196 283 wd_di()->get( Notification::class )->remove_settings();
197 284 wd_di()->get( Two_Factor::class )->remove_settings();
198 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 +
199 290 $this->set_intention( 'Data Reset' );
200 291 // Track first until settings are removed.
201 292 $this->track_opt( false );
202 293 $this->remove_settings();
294 + if ( ! $preserve_settings ) {
295 + update_site_option( Scan::FIRST_SCAN_STARTED, '0' );
296 + }
203 297 // Indicate that it is not a new installation.
204 298 defender_no_fresh_install();
205 299
206 300 return new Response(
@@ -242,26 +336,8 @@
242 336 public function remove_data() {
243 337 }
244 338
245 339 /**
246 - * Disconnect site from HUB.
247 - *
248 - * @defender_route
249 - * @return Response
250 - */
251 - public function disconnect_site(): Response {
252 - $this->logout();
253 -
254 - return new Response(
255 - true,
256 - array(
257 - 'message' => esc_html__( 'Site disconnected successfully!', 'defender-security' ),
258 - 'auto_close' => true,
259 - )
260 - );
261 - }
262 -
263 - /**
264 340 * Provides data for the frontend.
265 341 *
266 342 * @return array An array of data for the frontend.
267 343 */
@@ -268,17 +344,10 @@
268 344 public function data_frontend(): array {
269 345 $model = $this->get_model();
270 346
271 347 $this->service->maybe_create_default_config();
272 - $configs = $this->get_configs_and_update_status();
348 + $configs = $this->local_store->get_frontend_configs();
273 349
274 - foreach ( $configs as &$config ) {
275 - // Unset the data as we don't need it.
276 - if ( isset( $config['configs'] ) ) {
277 - unset( $config['configs'] );
278 - }
279 - }
280 -
281 350 $link = $this->wpmudev->is_member()
282 351 ? 'https://wpmudev.com/translate/projects/wpdef/'
283 352 : 'https://translate.wordpress.org/projects/wp-plugins/defender-security/';
284 353 $can_whitelabel = wd_di()->get( \WP_Defender\Integrations\Dashboard_Whitelabel::class )->can_whitelabel();
@@ -327,9 +396,14 @@
327 396 */
328 397 public function import_data( array $data ) {
329 398 $model = $this->get_model();
330 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 +
331 403 $model->import( $data );
404 + $model->usage_tracking = $current_usage_tracking;
405 +
332 406 if ( $model->validate() ) {
333 407 $model->save();
334 408 }
335 409 }
@@ -343,29 +417,29 @@
343 417 *
344 418 * @return bool Returns true if the importer data is valid, false otherwise.
345 419 */
346 420 private function validate_importer( $importer ): bool {
347 - if ( $this->service->verify_config_data( $importer ) ) {
348 - // Validate content. This is the current data, we use this for verify the schema.
349 - $sample = $this->service->gather_data();
350 - foreach ( $importer['configs'] as $slug => $module ) {
351 - // This is not in the sample, file is invalid.
352 - if ( ! isset( $sample[ $slug ] ) ) {
353 - return false;
354 - }
421 + if ( ! $this->service->verify_config_data( $importer ) || ! is_array( $importer['configs'] ) ) {
422 + return false;
423 + }
355 424
356 - $keys = array_keys( $sample[ $slug ] );
357 - $import_keys = array_keys( $module );
358 - $diff = array_diff( $import_keys, $keys );
359 - if ( count( $diff ) ) {
360 - return false;
361 - }
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 + }
362 431
363 - return true;
432 + if (
433 + ! is_array( $module )
434 + || ! is_array( $sample[ $slug ] )
435 + || array() !== array_diff_key( $module, $sample[ $slug ] )
436 + ) {
437 + return false;
364 438 }
365 439 }
366 440
367 - return false;
441 + return true;
368 442 }
369 443
370 444 /**
371 445 * Import config.
@@ -392,19 +466,8 @@
392 466 )
393 467 );
394 468 }
395 469
396 - // If it's old config structure then we upgrade configs to new format.
397 - if (
398 - isset( $importer['configs'] ) &&
399 - is_array( $importer['configs'] ) &&
400 - array() !== $importer['configs'] &&
401 - ! $this->service->check_for_new_structure( $importer['configs'] )
402 - ) {
403 - $adapter = wd_di()->get( Config_Adapter::class );
404 - $importer['configs'] = $adapter->upgrade( $importer['configs'] );
405 - }
406 -
407 470 if ( ! $this->validate_importer( $importer ) ) {
408 471 return new Response(
409 472 false,
410 473 array(
@@ -416,37 +479,70 @@
416 479 );
417 480 }
418 481
419 482 // Do not use wp_strip_all_tags() to prevent XSS attack.
420 - $name = sanitize_text_field( $importer['name'] );
421 - $configs = array(
422 - 'name' => $name,
423 - 'immortal' => false,
424 - 'is_removable' => true,
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(),
425 498 );
426 499
427 - $configs['configs'] = $importer['configs'];
428 - $configs['description'] = isset( $importer['description'] ) && is_string( $importer['description'] ) && '' !== $importer['description']
429 - ? sanitize_textarea_field( $importer['description'] )
430 - : '';
431 - $configs['strings'] = $this->service->import_module_strings( $importer );
432 - $key = 'wp_defender_config_import_' . time();
433 - update_site_option( $key, $configs );
434 - $this->service->index_key( $key );
500 + if ( ! $this->wpmudev->is_pro() ) {
501 + $sample = $this->service->gather_data();
502 + $pro_modules = array();
435 503
436 - return new Response(
437 - true,
438 - array(
439 - 'message' => sprintf(
440 - /* translators: %s: Config name. */
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. */
441 533 esc_html__(
442 - '%s config has been uploaded successfully – you can now apply it to this site.',
534 + 'This config included features not available in the Free version: %s. Only the available settings were imported.',
443 535 'defender-security'
444 536 ),
445 - '<strong>' . $name . '</strong>'
446 - ),
447 - 'configs' => Config_Hub_Helper::get_fresh_frontend_configs( $this->service ),
448 - )
537 + implode( ', ', $module_names )
538 + );
539 + }
540 + }
541 +
542 + return new Response(
543 + true,
544 + $response_data
449 545 );
450 546 }
451 547
452 548 /**
@@ -467,95 +563,196 @@
467 563 'message' => esc_html__( 'Invalid config name', 'defender-security' ),
468 564 )
469 565 );
470 566 }
471 - $name = sanitize_text_field( $name );
472 - $desc = isset( $data['desc'] ) && is_string( $data['desc'] ) && '' !== trim( $data['desc'] ) ? wp_kses_post( $data['desc'] ) : '';
473 - $key = 'wp_defender_config_' . time();
474 - $settings = $this->service->parse_data_for_import();
475 - $data = array_merge(
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,
476 581 array(
477 - 'name' => $name,
478 - 'immortal' => false,
479 - 'description' => $desc,
480 - 'is_removable' => true,
481 - ),
482 - $settings
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 + )
483 589 );
590 + }
484 591
485 - // Add config to HUB.
486 - $hub_id = Config_Hub_Helper::add_configs_to_hub( $data );
487 -
488 - if ( $hub_id ) {
489 - $data['hub_id'] = $hub_id;
490 - }
491 -
492 - unset( $data['labels'] );
493 -
494 - if ( update_site_option( $key, $data ) ) {
495 - $this->service->index_key( $key );
496 -
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 ) ) {
497 601 return new Response(
498 - true,
602 + false,
499 603 array(
500 - 'message' => sprintf(
501 - /* translators: %s: Config name. */
502 - esc_html__( '%s config saved successfully.', 'defender-security' ),
503 - '<strong>' . $name . '</strong>'
504 - ),
505 - 'configs' => Config_Hub_Helper::get_fresh_frontend_configs( $this->service ),
604 + 'message' => esc_html__( 'Invalid config', 'defender-security' ),
506 605 )
507 606 );
508 - } else {
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 ) {
509 614 return new Response(
510 615 false,
511 616 array(
512 - 'message' => esc_html__(
513 - 'An error occurred while saving your config. Please try it again.',
514 - 'defender-security'
515 - ),
617 + 'message' => esc_html__( 'Invalid config', 'defender-security' ),
516 618 )
517 619 );
518 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();
519 626 }
520 627
521 628 /**
522 - * Download config
629 + * Download multiple configs as a zip file.
523 630 *
524 631 * @return Response|void
525 632 * @defender_route
526 633 */
527 - public function download_config() {
528 - $key = defender_get_data_from_request( 'key', 'g' );
529 - if ( ! is_string( $key ) || '' === trim( $key ) ) {
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 ) ) {
530 637 return new Response(
531 638 false,
532 639 array(
533 - 'message' => esc_html__( 'Invalid config', 'defender-security' ),
640 + 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
534 641 )
535 642 );
536 643 }
537 644
538 - $config = get_site_option( $key );
539 - if ( false === $config ) {
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 ) {
540 660 return new Response(
541 661 false,
542 662 array(
543 - 'message' => esc_html__( 'Invalid config', 'defender-security' ),
663 + 'message' => esc_html__( 'Invalid config list', 'defender-security' ),
544 664 )
545 665 );
546 666 }
547 - $sample = $this->service->gather_data();
548 - foreach ( $sample as $slug => $data ) {
549 - foreach ( $data as $key => $val ) {
550 - if ( ! isset( $config['configs'][ $slug ][ $key ] ) ) {
551 - $config['configs'][ $slug ][ $key ] = null;
552 - }
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 );
553 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 + );
554 703 }
555 - $filename = 'wp-defender-config-' . sanitize_file_name( $config['name'] ) . '.json';
556 - header( 'Content-disposition: attachment; filename=' . $filename );
557 - echo wp_json_encode( $config, JSON_PRETTY_PRINT );
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();
558 755 exit();
559 756 }
560 757
561 758 /**
@@ -577,10 +774,13 @@
577 774 )
578 775 );
579 776 }
580 777
581 - $config = get_site_option( $key );
582 - if ( false === $config ) {
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 ) {
583 783 return new Response(
584 784 false,
585 785 array(
586 786 'message' => esc_html__( 'Invalid config', 'defender-security' ),
@@ -592,8 +792,9 @@
592 792 if ( is_string( $restore_result ) ) {
593 793 return $this->apply_config_recommendations_error_message();
594 794 }
595 795
796 + $this->local_store->apply( $key );
596 797 $this->service->make_config_active( $key );
597 798 // Track.
598 799 $this->track_feature(
599 800 'def_config_applied',
@@ -603,9 +804,9 @@
603 804 )
604 805 );
605 806
606 807 $message = sprintf(
607 - /* translators: %s: Config name. */
808 + /* translators: %s: Config name. */
608 809 esc_html__(
609 810 '%s config has been applied successfully.',
610 811 'defender-security'
611 812 ),
@@ -618,9 +819,9 @@
618 819 if ( $settings_mask_login->is_active() ) {
619 820 $login_url = $settings_mask_login->get_new_login_url();
620 821 }
621 822 $message .= '<br/>' . sprintf(
622 - /* translators: %s: Login link. */
823 + /* translators: %s: Login link. */
623 824 esc_html__(
624 825 'Due to currently applied security recommendations, you will now need to %s.',
625 826 'defender-security'
626 827 ),
@@ -643,9 +844,9 @@
643 844 }
644 845
645 846 $return['message'] = $message;
646 847 $return['auto_close'] = true;
647 - $return['configs'] = Config_Hub_Helper::get_fresh_frontend_configs( $this->service );
848 + $return['configs'] = $this->local_store->get_frontend_configs();
648 849
649 850 return new Response( true, $return );
650 851 }
651 852
@@ -659,10 +860,17 @@
659 860 */
660 861 public function update_config( Request $request ) {
661 862 $data = $request->get_data();
662 863 $key = trim( $data['key'] );
864 + $key = $this->resolve_config_key( $key );
663 865 $name = trim( $data['name'] );
664 - $description = trim( $data['desc'] );
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;
665 873 if ( '' === $name || '' === $key ) {
666 874 return new Response(
667 875 false,
668 876 array(
@@ -670,10 +878,13 @@
670 878 )
671 879 );
672 880 }
673 881
674 - $config = get_site_option( $key );
675 - if ( false === $config ) {
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 ) {
676 887 return new Response(
677 888 false,
678 889 array(
679 890 'message' => esc_html__( 'Invalid config', 'defender-security' ),
@@ -680,22 +891,17 @@
680 891 )
681 892 );
682 893 }
683 894
684 - $old_config = $config;
685 - $config['name'] = sanitize_text_field( $name );
686 - $config['description'] = sanitize_textarea_field( $description );
687 -
688 - // Check data has been changed or not.
689 - if (
690 - $old_config['name'] === $config['name'] &&
691 - $old_config['description'] === $config['description']
692 - ) {
693 - // Data is not changed, so not need to run update query.
694 - $option_updated = true;
695 - } else {
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 + }
696 903 $option_updated = update_site_option( $key, $config );
697 - Config_Hub_Helper::update_on_hub( $config );
698 904 }
699 905
700 906 if ( $option_updated ) {
701 907 return new Response(
@@ -701,14 +907,14 @@
701 907 return new Response(
702 908 true,
703 909 array(
704 910 'message' => sprintf(
705 - /* translators: %s: Config name. */
911 + /* translators: %s: Config name. */
706 912 esc_html__( '%s config saved successfully.', 'defender-security' ),
707 913 '<strong>' . $name . '</strong>'
708 914 ),
709 915 'auto_close' => true,
710 - 'configs' => Config_Hub_Helper::get_fresh_frontend_configs( $this->service ),
916 + 'configs' => $this->local_store->get_frontend_configs(),
711 917 )
712 918 );
713 919 } else {
714 920 return new Response(
@@ -723,8 +929,100 @@
723 929 }
724 930 }
725 931
726 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 + /**
727 1025 * Delete config.
728 1026 *
729 1027 * @param Request $request The request object containing config key.
730 1028 *
@@ -733,8 +1031,9 @@
733 1031 */
734 1032 public function delete_config( Request $request ) {
735 1033 $data = $request->get_data();
736 1034 $key = trim( $data['key'] );
1035 + $key = $this->resolve_config_key( $key, $this->local_store->get_all() );
737 1036 if ( '' === $key ) {
738 1037 return new Response(
739 1038 false,
740 1039 array(
@@ -742,9 +1041,12 @@
742 1041 )
743 1042 );
744 1043 }
745 1044
746 - $config = get_site_option( $key );
1045 + $config = $this->local_store->get_one( $key );
1046 + if ( null === $config ) {
1047 + $config = get_site_option( $key );
1048 + }
747 1049 if ( isset( $config['is_removable'] ) && false === (bool) $config['is_removable'] ) {
748 1050 return new Response(
749 1051 false,
750 1052 array(
@@ -752,16 +1054,24 @@
752 1054 )
753 1055 );
754 1056 }
755 1057
756 - // Remove from HUB.
757 - if ( isset( $config['hub_id'] ) ) {
758 - Config_Hub_Helper::delete_configs_from_hub( (int) $config['hub_id'] );
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 + );
759 1067 }
760 1068
761 1069 if ( 0 === strpos( $key, 'wp_defender_config' ) ) {
762 1070 delete_site_option( $key );
1071 + $this->service->remove_index( $key );
763 1072 $this->service->clear_keys();
1073 + delete_site_transient( Config_Hub_Helper::CONFIGS_TRANSIENT_KEY );
764 1074
765 1075 return new Response(
766 1076 true,
767 1077 array(
@@ -766,9 +1076,9 @@
766 1076 true,
767 1077 array(
768 1078 'message' => esc_html__( 'Config removed successfully.', 'defender-security' ),
769 1079 'auto_close' => true,
770 - 'configs' => Config_Hub_Helper::get_fresh_frontend_configs( $this->service ),
1080 + 'configs' => $this->local_store->get_frontend_configs(),
771 1081 )
772 1082 );
773 1083 }
774 1084
@@ -780,8 +1090,85 @@
780 1090 );
781 1091 }
782 1092
783 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 + /**
784 1171 * Exports strings.
785 1172 *
786 1173 * @return array An array of strings.
787 1174 */
@@ -789,48 +1176,53 @@
789 1176 return array();
790 1177 }
791 1178
792 1179 /**
793 - * Update config status and return them.
1180 + * Resolves a config key from an incoming identifier.
794 1181 *
795 - * @return array
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
796 1188 */
797 - private function get_configs_and_update_status() {
798 - $configs = Config_Hub_Helper::get_configs( $this->service );
799 - $is_remove = Config_Hub_Helper::check_remove_active_flag();
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 + }
800 1194
801 - // Loop to update strings of configs.
802 - foreach ( $configs as $key => &$config ) {
803 - if ( ! is_array( $config ) ) {
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'] ) ) {
804 1202 continue;
805 1203 }
806 -
807 - $config['strings'] = $this->service->import_module_strings( $config );
808 -
809 - if ( $is_remove ) {
810 - $config['is_active'] = false;
1204 + if ( (string) $config['hub_id'] === $key ) {
1205 + return (string) $config_key;
811 1206 }
812 -
813 - // Update config data.
814 - update_site_option( $key, $config );
815 1207 }
816 1208
817 - return $configs;
1209 + return $key;
818 1210 }
819 1211
820 1212 /**
821 - * Generates an error message for when there is an issue applying some tweaks from the Recommendations tab.
1213 + * Generates an error message for when there is an issue applying some tweaks from the Hardening tab.
822 1214 *
823 1215 * @return Response The response object containing the error message and fresh frontend configurations.
824 1216 */
825 1217 private function apply_config_recommendations_error_message(): Response {
826 1218 $message = sprintf(
827 - /* translators: 1: Recommendations tab, 2: wp-config.php file, 3: Documentation. */
1219 + /* translators: 1: Hardening tab, 2: wp-config.php file, 3: Documentation. */
828 1220 esc_html__(
829 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.',
830 1222 'defender-security'
831 1223 ),
832 - '<strong>' . esc_html__( 'Recommendations', 'defender-security' ) . '</strong>',
1224 + '<strong>' . esc_html__( 'Hardening', 'defender-security' ) . '</strong>',
833 1225 '<strong>' . esc_html__( 'wp-config.php', 'defender-security' ) . '</strong>',
834 1226 '<a href="' . WP_DEFENDER_DOCS_LINK . '#manually-applying-recommendations" target="_blank">' . esc_html__( 'documentation', 'defender-security' ) . '</a>'
835 1227 );
836 1228
@@ -837,11 +1229,74 @@
837 1229 return new Response(
838 1230 false,
839 1231 array(
840 1232 'message' => $message,
841 - 'configs' => Config_Hub_Helper::get_fresh_frontend_configs( $this->service ),
1233 + 'configs' => $this->local_store->get_frontend_configs(),
842 1234 )
843 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 + }
844 1299 }
845 1300
846 1301 /**
847 1302 * Clear out lines that are older than 30 days.