PluginProbe
MainWP Dashboard: Self-hosted WordPress Management for Agencies / 6.2.1
MainWP Dashboard: Self-hosted WordPress Management for Agencies v6.2.1
6.2.2 6.2.1 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 All 155 releases
← All changes | class/class-mainwp-api-manager.php +140 -4 6.0.11 → 6.2.1 View file →
@@ -122,12 +122,81 @@
122 122 if ( empty( $ext_key ) ) {
123 123 return array();
124 124 }
125 125
126 - 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 );
127 155 }
128 156
129 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 + /**
130 199 * Store activation info.
131 200 *
132 201 * @param mixed $ext_key Extension key.
133 202 * @param mixed $info Activation information.
@@ -143,12 +212,59 @@
143 212
144 213 // Clear cached of all activations to reload for next loading.
145 214 update_option( 'mainwp_extensions_all_activation_cached', '' );
146 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 +
147 238 return MainWP_Utility::update_option( $ext_key . '_APIManAdder', $info );
148 239 }
149 240
150 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 + /**
151 267 * Remove activation info.
152 268 *
153 269 * @param mixed $ext_key Extension key.
154 270 *
@@ -255,9 +371,18 @@
255 371 if ( ! empty( $error ) ) {
256 372 $return['error'] = $error;
257 373 }
258 374
259 - $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 + }
260 385
261 386 return $return;
262 387 } else {
263 388 return array( 'result' => 'SUCCESS' );
@@ -334,9 +459,14 @@
334 459 $options['api_key'] = '';
335 460 $options['activated_key'] = 'Deactivated';
336 461 }
337 462
338 - $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 + }
339 469
340 470 return $return;
341 471 }
342 472 // to fix: clear cached of all activations to reload for next loading.
@@ -480,9 +610,15 @@
480 610 if ( ! empty( $error ) ) {
481 611 $return['error'] = $error;
482 612 }
483 613
484 - $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 + }
485 621
486 622 return $return;
487 623 } else {
488 624 return array( 'error' => esc_html__( 'MainWP API key are required in order to grab extensions API keys.', 'mainwp' ) );