Skip to content

Commit 945ec01

Browse files
Merge pull request #1047 from 10up/feature/obfuscate-credentials
Obfuscate credentials before rendering to the front-end
2 parents d0e4c76 + 923824c commit 945ec01

File tree

4 files changed

+308
-6
lines changed

4 files changed

+308
-6
lines changed

includes/Classifai/Admin/Settings.php

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Classifai\Services\ServicesManager;
88
use Classifai\Taxonomy\TaxonomyFactory;
99
use Classifai\Helpers\CredentialReuse;
10+
use Classifai\Providers\CredentialObfuscator;
1011

1112
use function Classifai\get_asset_info;
1213
use function Classifai\get_plugin;
@@ -226,14 +227,18 @@ public function get_nlu_taxonomies(): array {
226227
/**
227228
* Get the settings.
228229
*
229-
* @return array The settings.
230+
* Obfuscates sensitive credentials before returning to prevent
231+
* exposure of API keys in the frontend.
232+
*
233+
* @return array The settings with credentials obfuscated.
230234
*/
231235
public function get_settings(): array {
232236
$features = $this->get_features( true );
233237
$settings = [];
234238

235239
foreach ( $features as $feature ) {
236-
$settings[ $feature::ID ] = $feature->get_settings();
240+
$feature_settings = $feature->get_settings();
241+
$settings[ $feature::ID ] = CredentialObfuscator::obfuscate_feature_settings( $feature_settings );
237242
}
238243

239244
return $settings;
@@ -472,6 +477,12 @@ public function update_settings_permissions_check(): bool {
472477
public function get_registration_settings_callback(): \WP_REST_Response {
473478
$service_manager = new ServicesManager();
474479
$settings = $service_manager->get_settings();
480+
481+
// Obfuscate the license key before returning.
482+
if ( isset( $settings['license_key'] ) ) {
483+
$settings['license_key'] = CredentialObfuscator::obfuscate( $settings['license_key'] );
484+
}
485+
475486
return rest_ensure_response( $settings );
476487
}
477488

@@ -487,12 +498,19 @@ public function update_registration_settings_callback( \WP_REST_Request $request
487498
require_once ABSPATH . 'wp-admin/includes/template.php';
488499
}
489500

490-
$service_manager = new ServicesManager();
491-
$settings = $service_manager->get_settings();
492-
$new_settings = $service_manager->sanitize_settings( $request->get_json_params() );
501+
$service_manager = new ServicesManager();
502+
$current_settings = $service_manager->get_settings();
503+
$new_settings = $request->get_json_params();
504+
505+
// If the license key is obfuscated, use the current value.
506+
if ( isset( $new_settings['license_key'] ) && CredentialObfuscator::is_obfuscated( $new_settings['license_key'] ) ) {
507+
$new_settings['license_key'] = $current_settings['license_key'] ?? '';
508+
}
509+
510+
$new_settings = $service_manager->sanitize_settings( $new_settings );
493511

494512
// Update the settings with the new values.
495-
$new_settings = array_merge( $settings, $new_settings );
513+
$new_settings = array_merge( $current_settings, $new_settings );
496514
update_option( 'classifai_settings', $new_settings );
497515

498516
$setting_errors = get_settings_errors();

includes/Classifai/Features/Feature.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use WP_REST_Request;
66
use WP_Error;
7+
use Classifai\Providers\CredentialObfuscator;
78

89
use function Classifai\find_provider_class;
910
use function Classifai\should_use_legacy_settings_panel;
@@ -301,6 +302,13 @@ public function sanitize_settings( array $settings ): array {
301302
// Sanitize the feature specific settings.
302303
$new_settings = $this->sanitize_default_feature_settings( $new_settings );
303304

305+
// Preserve obfuscated credentials for all Providers.
306+
// This ensures switching Providers doesn't save obfuscated values for inactive Providers.
307+
$new_settings = CredentialObfuscator::merge_all_provider_credentials(
308+
$new_settings,
309+
$current_settings
310+
);
311+
304312
// Sanitize the provider specific settings.
305313
$provider_instance = $this->get_feature_provider_instance( $new_settings['provider'] );
306314

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
<?php
2+
/**
3+
* Helper class for credential obfuscation functionality.
4+
*
5+
* @package Classifai
6+
*/
7+
8+
namespace Classifai\Providers;
9+
10+
use Classifai\Providers\ProviderProfiles;
11+
12+
if ( ! defined( 'ABSPATH' ) ) {
13+
exit;
14+
}
15+
16+
/**
17+
* CredentialObfuscator class.
18+
*
19+
* Handles obfuscation of sensitive credentials when displayed in the admin UI
20+
* and preserves original values when obfuscated values are submitted.
21+
*
22+
* @since x.x.x
23+
*/
24+
class CredentialObfuscator {
25+
26+
/**
27+
* Number of characters to show at the beginning of obfuscated values.
28+
*
29+
* @var int
30+
*/
31+
const VISIBLE_PREFIX_LENGTH = 8;
32+
33+
/**
34+
* Minimum number of consecutive asterisks to detect an obfuscated value.
35+
*
36+
* @var int
37+
*/
38+
const MIN_ASTERISKS_TO_DETECT = 3;
39+
40+
/**
41+
* Obfuscate a credential value.
42+
*
43+
* Returns first N characters followed by asterisks.
44+
* Example: "sk-abc123xyz789" becomes "sk-abc************"
45+
*
46+
* @since x.x.x
47+
*
48+
* @param string $value The credential value to obfuscate.
49+
* @return string The obfuscated value, or original if too short.
50+
*/
51+
public static function obfuscate( string $value ): string {
52+
if ( empty( $value ) ) {
53+
return $value;
54+
}
55+
56+
$length = strlen( $value );
57+
58+
// If the value is too short, just return asterisks.
59+
if ( $length <= self::VISIBLE_PREFIX_LENGTH && $length <= self::MIN_ASTERISKS_TO_DETECT ) {
60+
return str_repeat( '*', self::MIN_ASTERISKS_TO_DETECT );
61+
} elseif ( $length <= self::VISIBLE_PREFIX_LENGTH ) {
62+
return str_repeat( '*', $length );
63+
}
64+
65+
$prefix = substr( $value, 0, self::VISIBLE_PREFIX_LENGTH );
66+
$asterisks = str_repeat( '*', $length - self::VISIBLE_PREFIX_LENGTH );
67+
68+
// If we don't have enough asterisks, add more.
69+
if ( strlen( $asterisks ) < self::MIN_ASTERISKS_TO_DETECT ) {
70+
$asterisks = str_repeat( '*', self::MIN_ASTERISKS_TO_DETECT );
71+
}
72+
73+
return $prefix . $asterisks;
74+
}
75+
76+
/**
77+
* Check if a value appears to be obfuscated.
78+
*
79+
* Detects values containing 3 or more consecutive asterisks.
80+
*
81+
* @since x.x.x
82+
*
83+
* @param string $value The value to check.
84+
* @return bool True if the value appears to be obfuscated.
85+
*/
86+
public static function is_obfuscated( string $value ): bool {
87+
if ( empty( $value ) ) {
88+
return false;
89+
}
90+
91+
$pattern = '/\*{' . self::MIN_ASTERISKS_TO_DETECT . ',}/';
92+
return (bool) preg_match( $pattern, $value );
93+
}
94+
95+
/**
96+
* Check if a field should be obfuscated.
97+
*
98+
* Returns false for non-sensitive fields like authenticated, endpoint_url, etc.
99+
*
100+
* @since x.x.x
101+
*
102+
* @param string $field The field name to check.
103+
* @param string $profile_id The profile ID.
104+
* @return bool True if the field should be obfuscated.
105+
*/
106+
public static function should_obfuscate_field( string $field, string $profile_id ): bool {
107+
$sensitive_fields = ProviderProfiles::get_sensitive_fields( $profile_id );
108+
return in_array( $field, $sensitive_fields, true );
109+
}
110+
111+
/**
112+
* Obfuscate credential fields for a specific Provider.
113+
*
114+
* Uses ProviderProfiles to determine which fields are credentials.
115+
*
116+
* @since x.x.x
117+
*
118+
* @param string $provider_id The Provider ID (e.g., 'openai_chatgpt').
119+
* @param array $settings The Provider settings array.
120+
* @return array The settings with credentials obfuscated.
121+
*/
122+
public static function obfuscate_provider_settings( string $provider_id, array $settings ): array {
123+
$profile_id = ProviderProfiles::get_profile_for_provider( $provider_id );
124+
125+
if ( ! $profile_id ) {
126+
return $settings;
127+
}
128+
129+
$credential_fields = ProviderProfiles::get_credential_fields( $profile_id );
130+
131+
foreach ( $credential_fields as $field ) {
132+
if (
133+
isset( $settings[ $field ] ) &&
134+
is_string( $settings[ $field ] ) &&
135+
self::should_obfuscate_field( $field, $profile_id )
136+
) {
137+
$settings[ $field ] = self::obfuscate( $settings[ $field ] );
138+
}
139+
}
140+
141+
return $settings;
142+
}
143+
144+
/**
145+
* Obfuscate all Provider credentials in Feature settings.
146+
*
147+
* Iterates through all Provider settings in the Feature and obfuscates
148+
* their credential fields.
149+
*
150+
* @since x.x.x
151+
*
152+
* @param array $settings The complete Feature settings array.
153+
* @return array The settings with all Provider credentials obfuscated.
154+
*/
155+
public static function obfuscate_feature_settings( array $settings ): array {
156+
$profiles = ProviderProfiles::get_all_profiles();
157+
158+
// Get all Provider IDs from profiles.
159+
$all_provider_ids = [];
160+
foreach ( $profiles as $profile ) {
161+
$all_provider_ids = array_merge( $all_provider_ids, $profile['provider_ids'] );
162+
}
163+
164+
// Obfuscate credentials for each Provider that has settings.
165+
foreach ( $settings as $key => $value ) {
166+
if ( is_array( $value ) && in_array( $key, $all_provider_ids, true ) ) {
167+
$settings[ $key ] = self::obfuscate_provider_settings( $key, $value );
168+
}
169+
}
170+
171+
return $settings;
172+
}
173+
174+
/**
175+
* Merge new credentials with existing credentials, preserving originals when obfuscated.
176+
*
177+
* If a new value is obfuscated, use the existing value instead.
178+
* This prevents obfuscated placeholder values from being saved to the database.
179+
*
180+
* @since x.x.x
181+
*
182+
* @param array $new_settings The new settings being saved.
183+
* @param array $existing_settings The current saved settings.
184+
* @param string $provider_id The Provider ID.
185+
* @return array The merged settings.
186+
*/
187+
public static function merge_credentials( array $new_settings, array $existing_settings, string $provider_id ): array {
188+
$profile_id = ProviderProfiles::get_profile_for_provider( $provider_id );
189+
190+
if ( ! $profile_id ) {
191+
return $new_settings;
192+
}
193+
194+
$credential_fields = ProviderProfiles::get_credential_fields( $profile_id );
195+
196+
foreach ( $credential_fields as $field ) {
197+
// Skip non-sensitive fields.
198+
if ( ! self::should_obfuscate_field( $field, $profile_id ) ) {
199+
continue;
200+
}
201+
202+
// If the new value is obfuscated, preserve the existing value.
203+
if (
204+
isset( $new_settings[ $field ] ) &&
205+
is_string( $new_settings[ $field ] ) &&
206+
self::is_obfuscated( $new_settings[ $field ] ) &&
207+
isset( $existing_settings[ $field ] )
208+
) {
209+
$new_settings[ $field ] = $existing_settings[ $field ];
210+
}
211+
}
212+
213+
return $new_settings;
214+
}
215+
216+
/**
217+
* Merge credentials for all Providers in Feature settings.
218+
*
219+
* Iterates through all Provider settings and preserves existing credentials
220+
* when obfuscated values are submitted. This ensures switching Providers
221+
* doesn't save obfuscated values for inactive Providers.
222+
*
223+
* @since x.x.x
224+
*
225+
* @param array $new_settings The new Feature settings being saved.
226+
* @param array $existing_settings The current saved Feature settings.
227+
* @return array The settings with all Provider credentials properly merged.
228+
*/
229+
public static function merge_all_provider_credentials( array $new_settings, array $existing_settings ): array {
230+
$profiles = ProviderProfiles::get_all_profiles();
231+
232+
// Get all Provider IDs from profiles.
233+
$all_provider_ids = [];
234+
foreach ( $profiles as $profile ) {
235+
$all_provider_ids = array_merge( $all_provider_ids, $profile['provider_ids'] );
236+
}
237+
238+
// Merge credentials for each Provider that has settings.
239+
foreach ( $new_settings as $key => $value ) {
240+
if ( is_array( $value ) && in_array( $key, $all_provider_ids, true ) ) {
241+
$new_settings[ $key ] = self::merge_credentials(
242+
$value,
243+
$existing_settings[ $key ] ?? [],
244+
$key
245+
);
246+
}
247+
}
248+
249+
return $new_settings;
250+
}
251+
}

0 commit comments

Comments
 (0)