PluginProbe
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar / 3.3.3
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar v3.3.3
3.3.3 3.3.2 3.3.1 3.3.0 3.2.14 3.2.13 3.2.12 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 trunk 0.2.5.5 0.2.5.6 0.2.5.7 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 All 158 releases
← All changes | includes/Admin/Settings.php +200 -1 3.2.12 → 3.3.3 View file →
@@ -80,8 +80,20 @@
80 80 $data = NotificationX::get_instance()->normalize( $this->settings_form() );
81 81 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Reviewed for the NotificationX codebase: acceptable in this context.
82 82 $settings = apply_filters('nx_settings_page_settings', $this->get( 'settings', [] ));
83 83
84 + /*
85 + * `/builder` resolves `read_notificationx` -- the lowest capability the
86 + * product defines -- and this blob is handed straight to it. The
87 + * `settingsRedirect` flag below only tells the admin app not to render
88 + * the settings screen; it is a client-side routing hint, not a boundary.
89 + * Without this, a view-only delegate received every API key, client
90 + * secret and OAuth token stored on the site.
91 + */
92 + if ( ! current_user_can( 'edit_notificationx_settings' ) ) {
93 + $settings = self::redact_secret_settings( $settings );
94 + }
95 +
84 96 $data['current_page'] = 'settings';
85 97 $data['rest'] = REST::get_instance()->rest_data();
86 98 $data['savedValues'] = $settings;
87 99 $data['values'] = $settings;
@@ -669,8 +681,13 @@
669 681 $remove_before_save = [
670 682 'empty',
671 683 ];
672 684
685 + // Must run before the role map is derived below, or the posted values
686 + // would still reach `nx_settings_saved` and be written to the roles.
687 + $settings = $this->preserve_role_settings( $settings );
688 + $settings = $this->preserve_secret_settings( $settings );
689 +
673 690 $roles = $this->get_selected_roles( $settings );
674 691 $settings = array_merge( $settings, $roles );
675 692
676 693 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Reviewed for the NotificationX codebase: acceptable in this context.
@@ -680,18 +697,200 @@
680 697 if ( isset( $settings[ $key ] ) ) {
681 698 unset( $settings[ $key ] );
682 699 }
683 700 }
701 + $settings = $this->preserve_protected_settings( $settings );
702 +
684 703 // need this to ensure saved value don't return empty array instead of object.
685 704 if ( ! empty( $settings['delete_settings'] ) ) {
686 705 $settings = [ 'empty' => true ];
706 + // The reset discards everything preserved above, so re-apply the
707 + // role gate: a settings administrator must not be able to rewrite
708 + // role assignments by routing through a settings reset.
709 + $settings = $this->preserve_role_settings( $settings );
687 710 }
688 711
689 712 $this->set( 'settings', $settings );
690 - delete_transient( 'nx_get_field_names' );
713 + // Module toggles change which builder fields exist, and
714 + // NotificationX::normalize_post() coerces field types from this cache.
715 + delete_transient( 'nx_builder_fields' );
691 716 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Reviewed for the NotificationX codebase: acceptable in this context.
692 717 do_action( 'nx_settings_saved', $settings );
693 718 return true;
719 + }
720 +
721 + /**
722 + * Force server-owned settings back to their stored values.
723 + *
724 + * `save_settings()` replaces the whole blob with the posted one, so every key
725 + * the admin app is holding wins -- right for fields a merchant edits, wrong
726 + * for state only the server writes. OAuth tokens are the case that bites:
727 + * they live inside `settings`, so they are handed to the admin app on page
728 + * load and posted straight back. Disconnecting an account and then saving
729 + * from a tab opened *before* the disconnect wrote the revoked token back and
730 + * the integration silently kept working -- with Google Analytics that showed
731 + * up as live viewer counts on a supposedly disconnected site.
732 + *
733 + * Keys listed here can only be changed by the code that owns them.
734 + *
735 + * @param array $settings Incoming settings.
736 + * @return array
737 + */
738 + protected function preserve_protected_settings( $settings ) {
739 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Reviewed for the NotificationX codebase: acceptable in this context.
740 + $protected = apply_filters( 'nx_protected_settings', [
741 + 'nx_pa_settings', // Google Analytics OAuth token.
742 + ] );
743 +
744 + // `get()` returns false for a missing key, and false is also what a
745 + // disconnect stores -- so only a sentinel separates "stored as false"
746 + // from "never stored", and the two need different handling.
747 + $missing = new \stdClass();
748 +
749 + foreach ( (array) $protected as $key ) {
750 + if ( ! is_string( $key ) || '' === $key ) {
751 + continue;
752 + }
753 + $stored = $this->get( "settings.{$key}", $missing );
754 + if ( $missing === $stored ) {
755 + unset( $settings[ $key ] );
756 + continue;
757 + }
758 + $settings[ $key ] = $stored;
759 + }
760 +
761 + return $settings;
762 + }
763 +
764 + /**
765 + * Settings keys holding credentials rather than configuration.
766 + *
767 + * These are values a site owner pastes in once and which grant access to a
768 + * third-party account. They must never reach a client that is not entitled
769 + * to edit settings, and must never be written into an export file.
770 + *
771 + * Pro registers its own integrations, so the list is filterable; keep the
772 + * free baseline broad rather than minimal, because a key omitted here is a
773 + * key that leaks.
774 + *
775 + * @return array
776 + */
777 + public static function get_secret_settings_keys() {
778 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Reviewed for the NotificationX codebase: acceptable in this context.
779 + return (array) apply_filters( 'nx_secret_settings', [
780 + 'nx_pa_settings', // Google Analytics OAuth access + refresh token.
781 + 'token_info',
782 + 'ga_client_secret',
783 + 'yt_client_secret',
784 + 'openai_access_token',
785 + 'mailchimp_api_key',
786 + 'activecampaign_api_key',
787 + 'brevo_api_key',
788 + 'convertkit_api_key',
789 + 'convertkit_api_secret',
790 + 'google_review_api_key',
791 + 'google_youtube_api_key',
792 + 'zapier_api_key',
793 + 'ifttt_api_key',
794 + 'envato_token',
795 + 'gmap_token',
796 + ] );
797 + }
798 +
799 + /**
800 + * Strip credentials out of a settings blob.
801 + *
802 + * Keys are removed outright rather than blanked. A blanked value is
803 + * indistinguishable from "the user cleared this field" once it is posted
804 + * back, which would silently destroy working integrations; an absent key is
805 + * unambiguous and is restored by `preserve_secret_settings()` on save.
806 + *
807 + * @param array $settings
808 + * @return array
809 + */
810 + public static function redact_secret_settings( $settings ) {
811 + if ( ! is_array( $settings ) ) {
812 + return $settings;
813 + }
814 + foreach ( self::get_secret_settings_keys() as $key ) {
815 + unset( $settings[ $key ] );
816 + }
817 + return $settings;
818 + }
819 +
820 + /**
821 + * Carry stored credentials across a save that does not carry them.
822 + *
823 + * Counterpart to `redact_secret_settings()`. An export has its credentials
824 + * stripped, so importing one would otherwise blank every integration on the
825 + * target site. Absent key means "unchanged"; a key that *is* present wins,
826 + * so a settings administrator editing an API key in the UI still works, and
827 + * deliberately clearing a field still clears it.
828 + *
829 + * Unlike `preserve_protected_settings()` these values are user-owned, so
830 + * they must stay editable -- that is why this cannot simply force the
831 + * stored value back.
832 + *
833 + * @param array $settings Incoming settings.
834 + * @return array
835 + */
836 + protected function preserve_secret_settings( $settings ) {
837 + if ( ! is_array( $settings ) ) {
838 + return $settings;
839 + }
840 +
841 + $missing = new \stdClass();
842 +
843 + foreach ( self::get_secret_settings_keys() as $key ) {
844 + if ( array_key_exists( $key, $settings ) ) {
845 + continue;
846 + }
847 + $stored = $this->get( "settings.{$key}", $missing );
848 + if ( $missing !== $stored ) {
849 + $settings[ $key ] = $stored;
850 + }
851 + }
852 +
853 + return $settings;
854 + }
855 +
856 + /**
857 + * Keep role assignments out of reach of a plain settings administrator.
858 + *
859 + * The role selectors grant and revoke capabilities across every role on the
860 + * site, which is authority well beyond "may edit NotificationX settings".
861 + * Pro hides these fields from the settings form unless the user can
862 + * `delete_users` (RoleManagement::tab_advanced), but that is a filter on the
863 + * form schema -- it never runs on save, so posting the keys directly to
864 + * `/settings` bypassed it entirely.
865 + *
866 + * @param array $settings Incoming settings.
867 + * @return array
868 + */
869 + protected function preserve_role_settings( $settings ) {
870 + if ( ! is_array( $settings ) || current_user_can( 'delete_users' ) ) {
871 + return $settings;
872 + }
873 +
874 + $role_keys = [
875 + 'notification_view_roles',
876 + 'notification_roles',
877 + 'settings_roles',
878 + 'analytics_roles',
879 + ];
880 +
881 + $missing = new \stdClass();
882 +
883 + foreach ( $role_keys as $key ) {
884 + $stored = $this->get( "settings.{$key}", $missing );
885 + if ( $missing === $stored ) {
886 + unset( $settings[ $key ] );
887 + continue;
888 + }
889 + $settings[ $key ] = $stored;
890 + }
891 +
892 + return $settings;
694 893 }
695 894
696 895 public function get_role_map( $settings = [] ) {
697 896 if ( empty( $settings ) || count( $settings ) == 1 ) {