PluginProbe
MainWP Dashboard: Self-hosted WordPress Management for Agencies / trunk
MainWP Dashboard: Self-hosted WordPress Management for Agencies vtrunk
6.2 6.1.8 6.1.7 6.1.6 6.1.5 6.1.4 6.1.3 6.1.2 6.1.1 6.1 6.0.12 6.0.11 4.6.0.1 5.0 5.0.1 5.0.2 5.0.3 5.0.3.1 5.0.3.2 5.1 5.1.1 5.2 5.2.1 5.2.2 5.3 All 153 releases
← All changes | class/class-mainwp-api-manager.php +146 -4 5.2trunk View file →
@@ -11,8 +11,13 @@
11 11 */
12 12
13 13 namespace MainWP\Dashboard;
14 14
15 +// Exit if accessed directly.
16 +if ( ! defined( 'ABSPATH' ) ) {
17 + exit;
18 +}
19 +
15 20 /**
16 21 * Class MainWP_Api_Manager
17 22 *
18 23 * @package MainWP\Dashboard
@@ -117,12 +122,81 @@
117 122 if ( empty( $ext_key ) ) {
118 123 return array();
119 124 }
120 125
121 - return get_option( $ext_key . '_APIManAdder' );
126 + $info = get_option( $ext_key . '_APIManAdder' );
127 +
128 + // MWP-1546: per-extension license keys were historically stored as
129 + // plaintext inside the option array's 'api_key' field. New writes
130 + // (set_activation_info below) replace that field with a
131 + // {encrypted_val, file_key} envelope produced by the
132 + // mainwp_encrypt_key_value filter (MainWP_Keys_Manager). On read,
133 + // the matching mainwp_decrypt_key_value filter reverses the
134 + // envelope.
135 + //
136 + // Encrypt-on-first-read migration: if the stored row still carries
137 + // a plaintext api_key string (legacy installs upgraded from before
138 + // this change), rewrite it as the encrypted envelope right now.
139 + // Existing dashboards migrate transparently as each extension's
140 + // option is touched, without waiting for an unrelated activation
141 + // event. We persist via update_option directly rather than calling
142 + // set_activation_info to avoid churning the activations_cached
143 + // option on every legacy read.
144 + if ( is_array( $info ) && ! empty( $info['api_key'] ) && is_string( $info['api_key'] ) ) {
145 + $rewritten = static::encrypt_activation_info( $ext_key, $info );
146 + if ( is_array( $rewritten )
147 + && isset( $rewritten['api_key'] )
148 + && is_array( $rewritten['api_key'] )
149 + && ! empty( $rewritten['api_key']['encrypted_val'] ) ) {
150 + MainWP_Utility::update_option( $ext_key . '_APIManAdder', $rewritten );
151 + }
152 + }
153 +
154 + return static::decrypt_activation_info( $info );
122 155 }
123 156
124 157 /**
158 + * Reverse the api_key encryption applied by set_activation_info().
159 + *
160 + * Returns the input unchanged when api_key is missing, empty, or a
161 + * plaintext string (legacy format). Falls back to the original value
162 + * if the mainwp_decrypt_key_value filter cannot recover a string, so
163 + * a missing keyfile cannot orphan a license activation.
164 + *
165 + * @param mixed $info Raw option value as returned by get_option().
166 + * @return mixed Same shape as $info, with 'api_key' decrypted to plaintext.
167 + */
168 + public static function decrypt_activation_info( $info ) {
169 + if ( ! is_array( $info ) || empty( $info['api_key'] ) ) {
170 + return $info;
171 + }
172 + if ( ! is_array( $info['api_key'] ) ) {
173 + return $info; // Legacy plaintext string, no envelope to reverse.
174 + }
175 + if ( empty( $info['api_key']['encrypted_val'] ) ) {
176 + // Malformed envelope (array but missing encrypted_val). Drop the
177 + // unreadable value rather than returning the raw array; callers
178 + // (api-handler.php readers, the hooks filter) do not is_string-
179 + // guard the result and would forward the array to the licensing
180 + // API as http_build_query garbage. Mirrors the decrypt-failure
181 + // branch below.
182 + $info['api_key'] = '';
183 + return $info;
184 + }
185 + $decrypted = apply_filters( 'mainwp_decrypt_key_value', false, $info['api_key'], '' );
186 + if ( is_string( $decrypted ) && '' !== $decrypted ) {
187 + $info['api_key'] = $decrypted;
188 + } else {
189 + // Decrypt failed (e.g. missing keyfile). Drop the unreadable
190 + // ciphertext so callers see an empty string rather than the
191 + // raw envelope array, which they would treat as truthy and
192 + // forward to the licensing API as garbage.
193 + $info['api_key'] = '';
194 + }
195 + return $info;
196 + }
197 +
198 + /**
125 199 * Store activation info.
126 200 *
127 201 * @param mixed $ext_key Extension key.
128 202 * @param mixed $info Activation information.
@@ -138,12 +212,59 @@
138 212
139 213 // Clear cached of all activations to reload for next loading.
140 214 update_option( 'mainwp_extensions_all_activation_cached', '' );
141 215
216 + // MWP-1546: encrypt the 'api_key' field at rest using the same
217 + // mainwp_encrypt_key_value filter that 3rd-party API keys use. Other
218 + // array members (activated_key, deactivate_checkbox, product_id,
219 + // instance_id, software_version, mainwp_version, product_item_id)
220 + // are not credentials and stay plaintext for backwards compatibility
221 + // with any reader that consumes them directly.
222 + $info = static::encrypt_activation_info( $ext_key, $info );
223 +
224 + // Codex follow-up: fail closed when encryption did not produce an
225 + // envelope. encrypt_activation_info() returns the input unchanged
226 + // when the mainwp_encrypt_key_value filter fails (missing keyfile,
227 + // un-writable uploads dir, etc.), which would leave api_key as a
228 + // plaintext string and silently downgrade this write back to the
229 + // pre-MWP-1546 leak path. Refuse the write and let the caller
230 + // surface the error rather than persisting plaintext credentials.
231 + if ( is_array( $info )
232 + && isset( $info['api_key'] )
233 + && is_string( $info['api_key'] )
234 + && '' !== $info['api_key'] ) {
235 + return false;
236 + }
237 +
142 238 return MainWP_Utility::update_option( $ext_key . '_APIManAdder', $info );
143 239 }
144 240
145 241 /**
242 + * Encrypt the api_key field of an activation-info array via the
243 + * mainwp_encrypt_key_value filter (MainWP_Keys_Manager). Replaces the
244 + * plaintext string with the {encrypted_val, file_key} envelope.
245 + *
246 + * @param string $ext_key Extension slug; included in the keyfile prefix.
247 + * @param mixed $info Activation info as supplied by callers.
248 + * @return mixed Same shape as $info, with 'api_key' replaced by the
249 + * encryption envelope (or unchanged if encryption fails).
250 + */
251 + public static function encrypt_activation_info( $ext_key, $info ) {
252 + if ( ! is_array( $info ) ) {
253 + return $info;
254 + }
255 + if ( empty( $info['api_key'] ) || ! is_string( $info['api_key'] ) ) {
256 + return $info;
257 + }
258 + $prefix = 'extension_' . sanitize_key( $ext_key ) . '_';
259 + $envelope = apply_filters( 'mainwp_encrypt_key_value', false, $info['api_key'], $prefix, false );
260 + if ( is_array( $envelope ) && ! empty( $envelope['encrypted_val'] ) ) {
261 + $info['api_key'] = $envelope;
262 + }
263 + return $info;
264 + }
265 +
266 + /**
146 267 * Remove activation info.
147 268 *
148 269 * @param mixed $ext_key Extension key.
149 270 *
@@ -250,9 +371,18 @@
250 371 if ( ! empty( $error ) ) {
251 372 $return['error'] = $error;
252 373 }
253 374
254 - $this->set_activation_info( $api_slug, $options );
375 + // MWP-1546 follow-up: surface the fail-closed write so the user is not
376 + // told the activation succeeded when only the upstream half landed.
377 + // The license slot was already consumed at mainwp.com; without the
378 + // local persist the dashboard will offer to re-activate and burn a
379 + // second slot. The encrypt filter only fails on broken installs
380 + // (missing keyfile, un-writable uploads/mainwp/pk/), but when it
381 + // does the operator needs to know.
382 + if ( false === $this->set_activation_info( $api_slug, $options ) ) {
383 + $return['error'] = esc_html__( 'License activated upstream but local state could not be saved. Check uploads/mainwp/pk/ permissions and try the activation again.', 'mainwp' );
384 + }
255 385
256 386 return $return;
257 387 } else {
258 388 return array( 'result' => 'SUCCESS' );
@@ -329,9 +459,14 @@
329 459 $options['api_key'] = '';
330 460 $options['activated_key'] = 'Deactivated';
331 461 }
332 462
333 - $this->set_activation_info( $api_slug, $options );
463 + // MWP-1546 follow-up: surface the fail-closed write. The slot was
464 + // already released at mainwp.com; without the local persist the
465 + // dashboard would still display the extension as Activated.
466 + if ( false === $this->set_activation_info( $api_slug, $options ) ) {
467 + $return['error'] = esc_html__( 'License deactivated upstream but local state could not be cleared. Check uploads/mainwp/pk/ permissions.', 'mainwp' );
468 + }
334 469
335 470 return $return;
336 471 }
337 472 // to fix: clear cached of all activations to reload for next loading.
@@ -475,9 +610,15 @@
475 610 if ( ! empty( $error ) ) {
476 611 $return['error'] = $error;
477 612 }
478 613
479 - $this->set_activation_info( $api_slug, $options );
614 + // MWP-1546 follow-up: surface the fail-closed write. The grab
615 + // call may have produced a fresh license key upstream; if the
616 + // local persist fails the dashboard would lose track of it
617 + // entirely.
618 + if ( false === $this->set_activation_info( $api_slug, $options ) ) {
619 + $return['error'] = esc_html__( 'License retrieved upstream but local state could not be saved. Check uploads/mainwp/pk/ permissions and try again.', 'mainwp' );
620 + }
480 621
481 622 return $return;
482 623 } else {
483 624 return array( 'error' => esc_html__( 'MainWP API key are required in order to grab extensions API keys.', 'mainwp' ) );
@@ -563,8 +704,9 @@
563 704 case 'subscription_expired':
564 705 $return = esc_html__( 'Your membership has expired. Reactivate your membership to install MainWP extensions.', 'mainwp' );
565 706 break;
566 707 default: // download_revoked.
708 + /* translators: 1: Extension/software title, 2: URL to account dashboard */
567 709 $return = sprintf( esc_html__( 'Download permission for %1$s has been revoked possibly due to a license key or membership expiring. You can reactivate or purchase a license key from your account <a href="%2$s" target="_blank">dashboard</a>.', 'mainwp' ), $software_title, $this->renew_license_url );
568 710 break;
569 711 }
570 712 return $return;