From 96f7b22376123c5e0673951a952c24a241773536 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Tue, 3 Feb 2026 14:45:41 -0700 Subject: [PATCH 1/8] Add a new CredentialObfuscator class that will take Provider credentials that should remain private (like API key) and obfuscate those --- .../Providers/CredentialObfuscator.php | 256 ++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 includes/Classifai/Providers/CredentialObfuscator.php diff --git a/includes/Classifai/Providers/CredentialObfuscator.php b/includes/Classifai/Providers/CredentialObfuscator.php new file mode 100644 index 000000000..4beeea2e4 --- /dev/null +++ b/includes/Classifai/Providers/CredentialObfuscator.php @@ -0,0 +1,256 @@ + $value ) { + if ( is_array( $value ) && in_array( $key, $all_provider_ids, true ) ) { + $settings[ $key ] = self::obfuscate_provider_settings( $key, $value ); + } + } + + return $settings; + } + + /** + * Merge new credentials with existing credentials, preserving originals when obfuscated. + * + * If a new value is obfuscated, use the existing value instead. + * This prevents obfuscated placeholder values from being saved to the database. + * + * @since x.x.x + * + * @param array $new_settings The new settings being saved. + * @param array $existing_settings The current saved settings. + * @param string $provider_id The Provider ID. + * @return array The merged settings. + */ + public static function merge_credentials( array $new_settings, array $existing_settings, string $provider_id ): array { + $profile_id = ProviderProfiles::get_profile_for_provider( $provider_id ); + + if ( ! $profile_id ) { + return $new_settings; + } + + $credential_fields = ProviderProfiles::get_credential_fields( $profile_id ); + + foreach ( $credential_fields as $field ) { + // Skip non-sensitive fields. + if ( ! self::should_obfuscate_field( $field ) ) { + continue; + } + + // If the new value is obfuscated, preserve the existing value. + if ( + isset( $new_settings[ $field ] ) && + is_string( $new_settings[ $field ] ) && + self::is_obfuscated( $new_settings[ $field ] ) && + isset( $existing_settings[ $field ] ) + ) { + $new_settings[ $field ] = $existing_settings[ $field ]; + } + } + + return $new_settings; + } + + /** + * Merge credentials for all Providers in Feature settings. + * + * Iterates through all Provider settings and preserves existing credentials + * when obfuscated values are submitted. This ensures switching Providers + * doesn't save obfuscated values for inactive Providers. + * + * @since x.x.x + * + * @param array $new_settings The new Feature settings being saved. + * @param array $existing_settings The current saved Feature settings. + * @return array The settings with all Provider credentials properly merged. + */ + public static function merge_all_provider_credentials( array $new_settings, array $existing_settings ): array { + $profiles = ProviderProfiles::get_all_profiles(); + + // Get all Provider IDs from profiles. + $all_provider_ids = []; + foreach ( $profiles as $profile ) { + $all_provider_ids = array_merge( $all_provider_ids, $profile['provider_ids'] ); + } + + // Merge credentials for each Provider that has settings. + foreach ( $new_settings as $key => $value ) { + if ( is_array( $value ) && in_array( $key, $all_provider_ids, true ) ) { + $new_settings[ $key ] = self::merge_credentials( + $value, + $existing_settings[ $key ] ?? [], + $key + ); + } + } + + return $new_settings; + } +} From 2a733621ed0bdc2d347e84f6468c3a06d9c527a1 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Tue, 3 Feb 2026 14:46:16 -0700 Subject: [PATCH 2/8] When returning settings to our settings page handler, ensure we pass credentials through our new obfuscation class --- includes/Classifai/Admin/Settings.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/includes/Classifai/Admin/Settings.php b/includes/Classifai/Admin/Settings.php index 4436ef26d..991fb20cd 100644 --- a/includes/Classifai/Admin/Settings.php +++ b/includes/Classifai/Admin/Settings.php @@ -7,6 +7,7 @@ use Classifai\Services\ServicesManager; use Classifai\Taxonomy\TaxonomyFactory; use Classifai\Helpers\CredentialReuse; +use Classifai\Providers\CredentialObfuscator; use function Classifai\get_asset_info; use function Classifai\get_plugin; @@ -226,14 +227,18 @@ public function get_nlu_taxonomies(): array { /** * Get the settings. * - * @return array The settings. + * Obfuscates sensitive credentials before returning to prevent + * exposure of API keys in the frontend. + * + * @return array The settings with credentials obfuscated. */ public function get_settings(): array { $features = $this->get_features( true ); $settings = []; foreach ( $features as $feature ) { - $settings[ $feature::ID ] = $feature->get_settings(); + $feature_settings = $feature->get_settings(); + $settings[ $feature::ID ] = CredentialObfuscator::obfuscate_feature_settings( $feature_settings ); } return $settings; From 96a0710d9527c300cb8f57204232121ca75acef6 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Tue, 3 Feb 2026 14:46:32 -0700 Subject: [PATCH 3/8] When settings are saved, ensure we don't save obfuscated credentials --- includes/Classifai/Features/Feature.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/includes/Classifai/Features/Feature.php b/includes/Classifai/Features/Feature.php index 784a3af04..fb2ed2e5f 100644 --- a/includes/Classifai/Features/Feature.php +++ b/includes/Classifai/Features/Feature.php @@ -4,6 +4,7 @@ use WP_REST_Request; use WP_Error; +use Classifai\Providers\CredentialObfuscator; use function Classifai\find_provider_class; use function Classifai\should_use_legacy_settings_panel; @@ -301,6 +302,13 @@ public function sanitize_settings( array $settings ): array { // Sanitize the feature specific settings. $new_settings = $this->sanitize_default_feature_settings( $new_settings ); + // Preserve obfuscated credentials for all Providers. + // This ensures switching Providers doesn't save obfuscated values for inactive Providers. + $new_settings = CredentialObfuscator::merge_all_provider_credentials( + $new_settings, + $current_settings + ); + // Sanitize the provider specific settings. $provider_instance = $this->get_feature_provider_instance( $new_settings['provider'] ); From a2aa70d7f7d9a6e833d9f47073c7a46ed7c871ef Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Tue, 10 Feb 2026 09:00:08 -0700 Subject: [PATCH 4/8] Ensure our obfuscated string always has at least the minimum number of asterisks we look for --- includes/Classifai/Providers/CredentialObfuscator.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/includes/Classifai/Providers/CredentialObfuscator.php b/includes/Classifai/Providers/CredentialObfuscator.php index 4beeea2e4..b174d1e7b 100644 --- a/includes/Classifai/Providers/CredentialObfuscator.php +++ b/includes/Classifai/Providers/CredentialObfuscator.php @@ -77,6 +77,11 @@ public static function obfuscate( string $value ): string { $prefix = substr( $value, 0, self::VISIBLE_PREFIX_LENGTH ); $asterisks = str_repeat( '*', $length - self::VISIBLE_PREFIX_LENGTH ); + // If we don't have enough asterisks, add more. + if ( strlen( $asterisks ) < self::MIN_ASTERISKS_TO_DETECT ) { + $asterisks = str_repeat( '*', self::MIN_ASTERISKS_TO_DETECT ); + } + return $prefix . $asterisks; } From d0a84c5962defb3362a5d34fa72370677d0067f2 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Tue, 10 Feb 2026 09:19:38 -0700 Subject: [PATCH 5/8] Pull the Provider fields we should obfuscate from the ProviderProfiles class so we have more control over those fields --- .../Providers/CredentialObfuscator.php | 24 +++++------------- .../Classifai/Providers/ProviderProfiles.php | 25 +++++++++++++++++++ 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/includes/Classifai/Providers/CredentialObfuscator.php b/includes/Classifai/Providers/CredentialObfuscator.php index b174d1e7b..5eb355a67 100644 --- a/includes/Classifai/Providers/CredentialObfuscator.php +++ b/includes/Classifai/Providers/CredentialObfuscator.php @@ -37,20 +37,6 @@ class CredentialObfuscator { */ const MIN_ASTERISKS_TO_DETECT = 3; - /** - * Fields that should NOT be obfuscated (non-sensitive). - * - * @var array - */ - private static $non_sensitive_fields = [ - 'authenticated', - 'endpoint_url', - 'aws_region', - 'deployment', - 'username', - 'access_key_id', - ]; - /** * Obfuscate a credential value. * @@ -112,10 +98,12 @@ public static function is_obfuscated( string $value ): bool { * @since x.x.x * * @param string $field The field name to check. + * @param string $profile_id The profile ID. * @return bool True if the field should be obfuscated. */ - public static function should_obfuscate_field( string $field ): bool { - return ! in_array( $field, self::$non_sensitive_fields, true ); + public static function should_obfuscate_field( string $field, string $profile_id ): bool { + $sensitive_fields = ProviderProfiles::get_sensitive_fields( $profile_id ); + return in_array( $field, $sensitive_fields, true ); } /** @@ -142,7 +130,7 @@ public static function obfuscate_provider_settings( string $provider_id, array $ if ( isset( $settings[ $field ] ) && is_string( $settings[ $field ] ) && - self::should_obfuscate_field( $field ) + self::should_obfuscate_field( $field, $profile_id ) ) { $settings[ $field ] = self::obfuscate( $settings[ $field ] ); } @@ -205,7 +193,7 @@ public static function merge_credentials( array $new_settings, array $existing_s foreach ( $credential_fields as $field ) { // Skip non-sensitive fields. - if ( ! self::should_obfuscate_field( $field ) ) { + if ( ! self::should_obfuscate_field( $field, $profile_id ) ) { continue; } diff --git a/includes/Classifai/Providers/ProviderProfiles.php b/includes/Classifai/Providers/ProviderProfiles.php index e6b37e82e..6b60a1d25 100644 --- a/includes/Classifai/Providers/ProviderProfiles.php +++ b/includes/Classifai/Providers/ProviderProfiles.php @@ -39,71 +39,85 @@ class ProviderProfiles { 'openai_text_to_speech', ], 'credential_fields' => [ 'api_key', 'authenticated' ], + 'sensitive_fields' => [ 'api_key' ], 'label' => 'OpenAI', ], 'googleai' => [ 'provider_ids' => [ 'googleai_gemini_api', 'googleai_images' ], 'credential_fields' => [ 'api_key', 'authenticated' ], + 'sensitive_fields' => [ 'api_key' ], 'label' => 'Google AI', ], 'azure_openai' => [ 'provider_ids' => [ 'azure_openai' ], 'credential_fields' => [ 'endpoint_url', 'api_key', 'deployment', 'authenticated' ], + 'sensitive_fields' => [ 'api_key' ], 'label' => 'Azure OpenAI', ], 'azure_openai_embeddings' => [ 'provider_ids' => [ 'azure_openai_embeddings' ], 'credential_fields' => [ 'endpoint_url', 'api_key', 'deployment', 'authenticated' ], + 'sensitive_fields' => [ 'api_key' ], 'label' => 'Azure OpenAI Embeddings', ], 'ms_computer_vision' => [ 'provider_ids' => [ 'ms_computer_vision' ], 'credential_fields' => [ 'endpoint_url', 'api_key', 'authenticated' ], + 'sensitive_fields' => [ 'api_key' ], 'label' => 'Azure AI Vision', ], 'ms_azure_text_to_speech' => [ 'provider_ids' => [ 'ms_azure_text_to_speech' ], 'credential_fields' => [ 'endpoint_url', 'api_key', 'authenticated' ], + 'sensitive_fields' => [ 'api_key' ], 'label' => 'Azure Text to Speech', ], 'ibm_watson_nlu' => [ 'provider_ids' => [ 'ibm_watson_nlu' ], 'credential_fields' => [ 'endpoint_url', 'apikey', 'username', 'password', 'authenticated' ], + 'sensitive_fields' => [ 'apikey', 'password' ], 'label' => 'IBM Watson NLU', ], 'xai_grok' => [ 'provider_ids' => [ 'xai_grok' ], 'credential_fields' => [ 'api_key', 'authenticated' ], + 'sensitive_fields' => [ 'api_key' ], 'label' => 'XAI Grok', ], 'aws_polly' => [ 'provider_ids' => [ 'aws_polly' ], 'credential_fields' => [ 'access_key_id', 'secret_access_key', 'aws_region', 'authenticated' ], + 'sensitive_fields' => [ 'secret_access_key' ], 'label' => 'AWS Polly', ], 'elevenlabs' => [ 'provider_ids' => [ 'elevenlabs_text_to_speech', 'elevenlabs_speech_to_text' ], 'credential_fields' => [ 'api_key', 'authenticated' ], + 'sensitive_fields' => [ 'api_key' ], 'label' => 'ElevenLabs', ], 'togetherai_image' => [ 'provider_ids' => [ 'togetherai_image' ], 'credential_fields' => [ 'api_key', 'authenticated' ], + 'sensitive_fields' => [ 'api_key' ], 'label' => 'Together AI', ], 'stable_diffusion' => [ 'provider_ids' => [ 'stable_diffusion' ], 'credential_fields' => [ 'endpoint_url', 'authenticated' ], + 'sensitive_fields' => [], 'label' => 'Stable Diffusion', ], 'ollama' => [ 'provider_ids' => [ 'ollama', 'ollama_embeddings', 'ollama_multimodal' ], 'credential_fields' => [ 'endpoint_url', 'authenticated' ], + 'sensitive_fields' => [], 'label' => 'Ollama', ], 'chrome_ai' => [ 'provider_ids' => [ 'chrome_ai' ], 'credential_fields' => [], + 'sensitive_fields' => [], 'label' => 'Chrome AI', ], ]; @@ -166,4 +180,15 @@ public static function get_credential_fields( string $profile_id ): array { $profiles = self::get_all_profiles(); return $profiles[ $profile_id ]['credential_fields'] ?? []; } + + /** + * Get sensitive field names for a profile. + * + * @param string $profile_id Profile ID. + * @return array List of field names. + */ + public static function get_sensitive_fields( string $profile_id ): array { + $profiles = self::get_all_profiles(); + return $profiles[ $profile_id ]['sensitive_fields'] ?? []; + } } From 17c2112b1451e78b9137be5119e7e82d63f4df44 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Tue, 10 Feb 2026 09:35:28 -0700 Subject: [PATCH 6/8] Obfuscate our ClassifAI license key as well --- includes/Classifai/Admin/Settings.php | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/includes/Classifai/Admin/Settings.php b/includes/Classifai/Admin/Settings.php index 991fb20cd..073659cbf 100644 --- a/includes/Classifai/Admin/Settings.php +++ b/includes/Classifai/Admin/Settings.php @@ -477,6 +477,10 @@ public function update_settings_permissions_check(): bool { public function get_registration_settings_callback(): \WP_REST_Response { $service_manager = new ServicesManager(); $settings = $service_manager->get_settings(); + + // Obfuscate the license key before returning. + $settings['license_key'] = CredentialObfuscator::obfuscate( $settings['license_key'] ); + return rest_ensure_response( $settings ); } @@ -492,12 +496,19 @@ public function update_registration_settings_callback( \WP_REST_Request $request require_once ABSPATH . 'wp-admin/includes/template.php'; } - $service_manager = new ServicesManager(); - $settings = $service_manager->get_settings(); - $new_settings = $service_manager->sanitize_settings( $request->get_json_params() ); + $service_manager = new ServicesManager(); + $current_settings = $service_manager->get_settings(); + $new_settings = $request->get_json_params(); + + // If the license key is obfuscated, use the current value. + if ( isset( $new_settings['license_key'] ) && CredentialObfuscator::is_obfuscated( $new_settings['license_key'] ) ) { + $new_settings['license_key'] = $current_settings['license_key'] ?? ''; + } + + $new_settings = $service_manager->sanitize_settings( $new_settings ); // Update the settings with the new values. - $new_settings = array_merge( $settings, $new_settings ); + $new_settings = array_merge( $current_settings, $new_settings ); update_option( 'classifai_settings', $new_settings ); $setting_errors = get_settings_errors(); From 077d5f67b8ee7ff70d286e2ec699feaa2fa4ed41 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Tue, 10 Feb 2026 10:40:27 -0700 Subject: [PATCH 7/8] Fix fatal error when settings aren't set --- includes/Classifai/Admin/Settings.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/includes/Classifai/Admin/Settings.php b/includes/Classifai/Admin/Settings.php index 073659cbf..3334e04dd 100644 --- a/includes/Classifai/Admin/Settings.php +++ b/includes/Classifai/Admin/Settings.php @@ -479,7 +479,9 @@ public function get_registration_settings_callback(): \WP_REST_Response { $settings = $service_manager->get_settings(); // Obfuscate the license key before returning. - $settings['license_key'] = CredentialObfuscator::obfuscate( $settings['license_key'] ); + if ( isset( $settings['license_key'] ) ) { + $settings['license_key'] = CredentialObfuscator::obfuscate( $settings['license_key'] ); + } return rest_ensure_response( $settings ); } From f16a21d80a0e80bbc3cbd3cf7b72accaf932370d Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Wed, 11 Feb 2026 09:54:15 +1100 Subject: [PATCH 8/8] Ensure short values have enough asterisks. --- includes/Classifai/Providers/CredentialObfuscator.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/includes/Classifai/Providers/CredentialObfuscator.php b/includes/Classifai/Providers/CredentialObfuscator.php index 5eb355a67..f92c588c6 100644 --- a/includes/Classifai/Providers/CredentialObfuscator.php +++ b/includes/Classifai/Providers/CredentialObfuscator.php @@ -56,7 +56,9 @@ public static function obfuscate( string $value ): string { $length = strlen( $value ); // If the value is too short, just return asterisks. - if ( $length <= self::VISIBLE_PREFIX_LENGTH ) { + if ( $length <= self::VISIBLE_PREFIX_LENGTH && $length <= self::MIN_ASTERISKS_TO_DETECT ) { + return str_repeat( '*', self::MIN_ASTERISKS_TO_DETECT ); + } elseif ( $length <= self::VISIBLE_PREFIX_LENGTH ) { return str_repeat( '*', $length ); }