Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 2 additions & 17 deletions inc/admin-pages/class-hosting-integration-wizard-admin-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ public function section_configuration(): void {
$fields = $this->integration->get_fields();

foreach ($fields as $field_constant => &$field) {
$field['value'] = defined($field_constant) && constant($field_constant) ? constant($field_constant) : '';
$field['value'] = $this->integration->get_credential($field_constant);
}

$form = new \WP_Ultimo\UI\Form(
Expand Down Expand Up @@ -319,22 +319,7 @@ public function handle_configuration(): void {
}
}

if ((int) wu_request('submit') === 0) {
$redirect_url = add_query_arg(
[
'manual' => '1',
'post' => wp_json_encode($filtered_data),
]
);

wp_safe_redirect($redirect_url);

exit;
}

if ((int) wu_request('submit') === 1) {
$this->integration->setup_constants($filtered_data);
}
$this->integration->save_credentials($filtered_data);

$redirect_url = $this->get_next_section_link();

Expand Down
123 changes: 123 additions & 0 deletions inc/helpers/class-credential-store.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<?php
/**
* Credential Store helper for encrypting/decrypting hosting integration credentials.
*
* @package WP_Ultimo
* @subpackage Helpers
* @since 2.3.0
*/

namespace WP_Ultimo\Helpers;

// Exit if accessed directly
defined('ABSPATH') || exit;

/**
* Handles encryption and decryption of hosting credentials stored in network options.
*
* @since 2.3.0
*/
class Credential_Store {

/**
* The cipher method used for encryption.
*
* @var string
*/
const CIPHER_METHOD = 'aes-256-cbc';

/**
* Prefix for encrypted values to identify them.
*
* @var string
*/
const ENCRYPTED_PREFIX = '$wu_enc$';

/**
* Encrypt a value for storage.
*
* @since 2.3.0
*
* @param string $value The plaintext value to encrypt.
* @return string The encrypted value.
*/
public static function encrypt(string $value): string {

if (empty($value)) {
return '';
}

if ( ! function_exists('openssl_encrypt') || ! in_array(self::CIPHER_METHOD, openssl_get_cipher_methods(), true)) {
return self::ENCRYPTED_PREFIX . base64_encode($value); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
}
Comment on lines +50 to +52
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Silent fallback to base64-only encoding degrades security.

When OpenSSL is unavailable or encryption fails, the code silently falls back to base64 encoding without actual encryption. This could leave credentials exposed if:

  1. The PHP installation lacks OpenSSL
  2. Encryption fails for any reason

Consider either:

  • Logging a warning when falling back to non-encrypted storage
  • Throwing an exception to prevent insecure storage
  • At minimum, documenting this behavior for administrators
🛡️ Proposed fix to add logging for fallback behavior
 		if ( ! function_exists('openssl_encrypt') || ! in_array(self::CIPHER_METHOD, openssl_get_cipher_methods(), true)) {
+			wu_log_add('credential-store', __('OpenSSL not available - credentials stored with obfuscation only, not encryption.', 'ultimate-multisite'), \Psr\Log\LogLevel::WARNING);
 			return self::ENCRYPTED_PREFIX . base64_encode($value); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
 		}

And for encryption failure:

 		if (false === $encrypted) {
+			wu_log_add('credential-store', __('Encryption failed - credentials stored with obfuscation only, not encryption.', 'ultimate-multisite'), \Psr\Log\LogLevel::WARNING);
 			return self::ENCRYPTED_PREFIX . base64_encode($value); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
 		}

Also applies to: 59-61

🤖 Prompt for AI Agents
In `@inc/helpers/class-credential-store.php` around lines 50 - 52, The code
currently falls back to base64-only storage silently when OpenSSL is unavailable
or encryption fails (see usage of openssl_encrypt, openssl_get_cipher_methods,
self::CIPHER_METHOD and self::ENCRYPTED_PREFIX + base64_encode); update the
encrypt/decrypt logic in the credential store (the methods wrapping
openssl_encrypt/openssl_decrypt) to explicitly handle two cases: (1) if openssl
functions or the cipher are missing, emit a warning log (use the plugin logger
or error_log/WP_Error) that includes the exact reason and avoid silently
persisting sensitive data without notice, and (2) if openssl_encrypt fails at
runtime, log the error with the failure details and either throw an exception or
return a failure result instead of returning the base64-only value; apply the
same explicit logging/exception behavior for the symmetric decryption path
(lines around the other fallback at 59-61) so both encryption and decryption
failures are surfaced.


$key = self::get_encryption_key();
$iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length(self::CIPHER_METHOD));

$encrypted = openssl_encrypt($value, self::CIPHER_METHOD, $key, 0, $iv);

if (false === $encrypted) {
return self::ENCRYPTED_PREFIX . base64_encode($value); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
}

return self::ENCRYPTED_PREFIX . base64_encode($iv . '::' . $encrypted); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
}

/**
* Decrypt a stored value.
*
* @since 2.3.0
*
* @param string $value The encrypted value to decrypt.
* @return string The decrypted plaintext value.
*/
public static function decrypt(string $value): string {

if (empty($value)) {
return '';
}

if (strpos($value, self::ENCRYPTED_PREFIX) !== 0) {
return $value;
}

$encoded = substr($value, strlen(self::ENCRYPTED_PREFIX));
$decoded = base64_decode($encoded); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode

if (false === $decoded) {
return '';
}

if (strpos($decoded, '::') === false) {
return $decoded;
}

if ( ! function_exists('openssl_decrypt') || ! in_array(self::CIPHER_METHOD, openssl_get_cipher_methods(), true)) {
return $decoded;
}

$parts = explode('::', $decoded, 2);

if (count($parts) !== 2) {
return '';
}

[$iv, $encrypted] = $parts;

$key = self::get_encryption_key();
$decrypted = openssl_decrypt($encrypted, self::CIPHER_METHOD, $key, 0, $iv);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

return false === $decrypted ? '' : $decrypted;
}

/**
* Get the encryption key derived from WordPress salts.
*
* @since 2.3.0
* @return string
*/
private static function get_encryption_key(): string {

return hash('sha256', wp_salt('auth'), true);
}
}
65 changes: 63 additions & 2 deletions inc/integrations/host-providers/class-base-host-provider.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

namespace WP_Ultimo\Integrations\Host_Providers;

use WP_Ultimo\Helpers\Credential_Store;
use WP_Ultimo\Helpers\WP_Config;

// Exit if accessed directly
Expand Down Expand Up @@ -398,6 +399,66 @@ public function load_dependencies() {}
*/
abstract public function detect();

/**
* Retrieves a credential value by constant name.
*
* Constants defined in wp-config.php take priority over stored network options.
*
* @since 2.3.0
*
* @param string $constant_name The constant name to look up.
* @return string The credential value, or empty string if not found.
*/
public function get_credential(string $constant_name): string {

if (defined($constant_name) && constant($constant_name)) {
return (string) constant($constant_name);
}

$stored = get_network_option(null, 'wu_hosting_credential_' . $constant_name, '');

if ( ! empty($stored)) {
return Credential_Store::decrypt($stored);
}

return '';
}

/**
* Saves credential values as encrypted network options.
*
* @since 2.3.0
*
* @param array $constant_values Key => Value pairs of credential constants.
* @return void
*/
public function save_credentials(array $constant_values): void {

$allowed = array_flip($this->get_all_constants());
$values = shortcode_atts($allowed, $constant_values);

foreach ($values as $constant_name => $value) {
if ( ! empty($value)) {
update_network_option(null, 'wu_hosting_credential_' . $constant_name, Credential_Store::encrypt($value));
} else {
delete_network_option(null, 'wu_hosting_credential_' . $constant_name);
}
}
}

/**
* Deletes all stored credentials for this integration.
*
* @since 2.3.0
* @return void
*/
public function delete_credentials(): void {

foreach ($this->get_all_constants() as $constant_name) {
delete_network_option(null, 'wu_hosting_credential_' . $constant_name);
}
}

/**
* Checks if the integration is correctly setup after enabled.
*
Expand All @@ -414,7 +475,7 @@ public function is_setup() {
$current = false;

foreach ($constants as $constant) {
if (defined($constant) && constant($constant)) {
if ($this->get_credential($constant)) {
$current = true;

break;
Expand Down Expand Up @@ -450,7 +511,7 @@ public function get_missing_constants() {
$current = false;

foreach ($constants as $constant) {
if (defined($constant) && constant($constant)) {
if ($this->get_credential($constant)) {
$current = true;

break;
Expand Down
19 changes: 5 additions & 14 deletions inc/integrations/host-providers/class-closte-host-provider.php
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ public function ssl_tries($max_tries, $domain) {
*/
public function detect() {

return defined('CLOSTE_CLIENT_API_KEY') && CLOSTE_CLIENT_API_KEY;
return (bool) $this->get_credential('CLOSTE_CLIENT_API_KEY');
}

/**
Expand Down Expand Up @@ -304,25 +304,16 @@ public function test_connection(): void {
*/
public function send_closte_api_request($endpoint, $data) {

if (defined('CLOSTE_CLIENT_API_KEY') === false) {
wu_log_add('integration-closte', 'CLOSTE_CLIENT_API_KEY constant not defined');
return [
'success' => false,
'error' => 'Closte API Key not found.',
];
}
$api_key = $this->get_credential('CLOSTE_CLIENT_API_KEY');

if (empty(CLOSTE_CLIENT_API_KEY)) {
wu_log_add('integration-closte', 'CLOSTE_CLIENT_API_KEY is empty');
if (empty($api_key)) {
wu_log_add('integration-closte', 'CLOSTE_CLIENT_API_KEY constant not defined or empty');
return [
'success' => false,
'error' => 'Closte API Key is empty.',
'error' => 'Closte API Key not found.',
];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Update the log/error text to reflect credential storage, not constants.

The message now mentions constants, but credentials may come from the store. A neutral “API key not configured” wording will be clearer.

🔧 Suggested wording update
-		if (empty($api_key)) {
-			wu_log_add('integration-closte', 'CLOSTE_CLIENT_API_KEY constant not defined or empty');
+		if (empty($api_key)) {
+			wu_log_add('integration-closte', 'Closte API key not configured.');
 			return [
 				'success' => false,
-				'error'   => 'Closte API Key not found.',
+				'error'   => 'Closte API key not configured.',
 			];
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$api_key = $this->get_credential('CLOSTE_CLIENT_API_KEY');
if (empty(CLOSTE_CLIENT_API_KEY)) {
wu_log_add('integration-closte', 'CLOSTE_CLIENT_API_KEY is empty');
if (empty($api_key)) {
wu_log_add('integration-closte', 'CLOSTE_CLIENT_API_KEY constant not defined or empty');
return [
'success' => false,
'error' => 'Closte API Key is empty.',
'error' => 'Closte API Key not found.',
];
$api_key = $this->get_credential('CLOSTE_CLIENT_API_KEY');
if (empty($api_key)) {
wu_log_add('integration-closte', 'Closte API key not configured.');
return [
'success' => false,
'error' => 'Closte API key not configured.',
];
}
🤖 Prompt for AI Agents
In `@inc/integrations/host-providers/class-closte-host-provider.php` around lines
307 - 314, Update the messaging around the CLOSTE_CLIENT_API_KEY check (the
get_credential('CLOSTE_CLIENT_API_KEY') block) to use neutral language about
missing configuration rather than mentioning constants: change the
wu_log_add('integration-closte', ...) message and the returned 'error' string so
they say the Closte API key or API key is not configured / not found (e.g.,
"Closte API key not configured" or "API key not configured") instead of
referencing constants; keep the same control flow and keys in the returned array
and do this inside the same function/method where get_credential is called.

}

// Try different authentication methods
$api_key = CLOSTE_CLIENT_API_KEY;

$post_fields = [
'blocking' => true,
'timeout' => 45,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ public function add_cloudflare_dns_entries($dns_records, $domain) {

$zone_ids = [];

$default_zone_id = defined('WU_CLOUDFLARE_ZONE_ID') && WU_CLOUDFLARE_ZONE_ID ? WU_CLOUDFLARE_ZONE_ID : false;
$default_zone_id = $this->get_credential('WU_CLOUDFLARE_ZONE_ID') ?: false;

if ($default_zone_id) {
$zone_ids[] = $default_zone_id;
Expand Down Expand Up @@ -235,7 +235,7 @@ public function on_add_subdomain($subdomain, $site_id): void {

global $current_site;

$zone_id = defined('WU_CLOUDFLARE_ZONE_ID') && WU_CLOUDFLARE_ZONE_ID ? WU_CLOUDFLARE_ZONE_ID : '';
$zone_id = $this->get_credential('WU_CLOUDFLARE_ZONE_ID');

if ( ! $zone_id) {
return;
Expand Down Expand Up @@ -312,7 +312,7 @@ public function on_remove_subdomain($subdomain, $site_id): void {

global $current_site;

$zone_id = defined('WU_CLOUDFLARE_ZONE_ID') && WU_CLOUDFLARE_ZONE_ID ? WU_CLOUDFLARE_ZONE_ID : '';
$zone_id = $this->get_credential('WU_CLOUDFLARE_ZONE_ID');

if ( ! $zone_id) {
return;
Expand Down Expand Up @@ -390,7 +390,7 @@ protected function cloudflare_api_call($endpoint = 'client/v4/user/tokens/verify
'body' => 'GET' === $method ? $data : wp_json_encode($data),
'data_format' => 'body',
'headers' => [
'Authorization' => sprintf('Bearer %s', defined('WU_CLOUDFLARE_API_KEY') ? WU_CLOUDFLARE_API_KEY : ''),
'Authorization' => sprintf('Bearer %s', $this->get_credential('WU_CLOUDFLARE_API_KEY')),
'Content-Type' => 'application/json',
],
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -398,10 +398,10 @@ protected function get_domain_list() {

$domain_list = $this->get_all_mapped_domains();

$extra_domains = defined('WU_CLOUDWAYS_EXTRA_DOMAINS') && WU_CLOUDWAYS_EXTRA_DOMAINS;
$extra_domains = $this->get_credential('WU_CLOUDWAYS_EXTRA_DOMAINS');

if ($extra_domains) {
$extra_domains_list = array_filter(array_map('trim', explode(',', (string) WU_CLOUDWAYS_EXTRA_DOMAINS)));
$extra_domains_list = array_filter(array_map('trim', explode(',', $extra_domains)));

$domain_list = array_merge($domain_list, $extra_domains_list);
}
Expand Down Expand Up @@ -430,8 +430,8 @@ protected function get_cloudways_access_token() {
'content-type' => 'application/x-www-form-urlencoded',
],
'body' => [
'email' => defined('WU_CLOUDWAYS_EMAIL') ? WU_CLOUDWAYS_EMAIL : '',
'api_key' => defined('WU_CLOUDWAYS_API_KEY') ? WU_CLOUDWAYS_API_KEY : '',
'email' => $this->get_credential('WU_CLOUDWAYS_EMAIL'),
'api_key' => $this->get_credential('WU_CLOUDWAYS_API_KEY'),
],
]
);
Expand Down Expand Up @@ -473,15 +473,15 @@ protected function send_cloudways_request($endpoint, $data = [], $method = 'POST
if ('GET' === $method) {
$endpoint_url = add_query_arg(
[
'server_id' => defined('WU_CLOUDWAYS_SERVER_ID') ? WU_CLOUDWAYS_SERVER_ID : '',
'app_id' => defined('WU_CLOUDWAYS_APP_ID') ? WU_CLOUDWAYS_APP_ID : '',
'server_id' => $this->get_credential('WU_CLOUDWAYS_SERVER_ID'),
'app_id' => $this->get_credential('WU_CLOUDWAYS_APP_ID'),
],
$endpoint_url
);
} else {
$data['server_id'] = defined('WU_CLOUDWAYS_SERVER_ID') ? WU_CLOUDWAYS_SERVER_ID : '';
$data['app_id'] = defined('WU_CLOUDWAYS_APP_ID') ? WU_CLOUDWAYS_APP_ID : '';
$data['ssl_email'] = defined('WU_CLOUDWAYS_EMAIL') ? WU_CLOUDWAYS_EMAIL : '';
$data['server_id'] = $this->get_credential('WU_CLOUDWAYS_SERVER_ID');
$data['app_id'] = $this->get_credential('WU_CLOUDWAYS_APP_ID');
$data['ssl_email'] = $this->get_credential('WU_CLOUDWAYS_EMAIL');
$data['wild_card'] = false;
}

Expand Down
Loading
Loading