PluginProbe
Content Control – The Ultimate Content Restriction Plugin! Restrict Content, Create Conditional Blocks & More / 2.6.2
Content Control – The Ultimate Content Restriction Plugin! Restrict Content, Create Conditional Blocks & More v2.6.2
trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.10 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 2.0.0 2.0.1 2.0.10 2.0.11 2.0.12 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 All 47 releases
content-control / classes / Plugin / Connect.php

Connect.php in Content Control – The Ultimate Content Restriction Plugin! Restrict Content, Create Conditional Blocks & More 2.6.2, at classes/Plugin/Connect.php

535 lines 14.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Connect.
4 *
5 * @copyright (c) 2023, Code Atlantic LLC.
6 *
7 * @package ContentControl
8 */
9
10 namespace ContentControl\Plugin;
11
12 use function ContentControl\plugin;
13
14 defined( 'ABSPATH' ) || exit;
15
16 /**
17 * Connection management.
18 *
19 * NOTE: For wordpress.org admins: This is not called in the free, hosted version. This is only used if:
20 * - The user explicitly entered a license key.
21 * - This then opens a window to our site allowing the user to authorize the connection & installation of pro.
22 *
23 * @package ContentControl
24 */
25 class Connect {
26
27 const API_URL = 'https://upgrade.contentcontrolplugin.com/';
28 const TOKEN_OPTION_NAME = 'content_control_connect_token';
29 const NONCE_OPTION_NAME = 'content_control_connect_nonce';
30
31 const ERROR_REFERRER = 1;
32 const ERROR_AUTHENTICATION = 2;
33 const ERROR_USER_AGENT = 3;
34 const ERROR_SIGNATURE = 4;
35 const ERROR_NONCE = 5;
36 const ERROR_WEBHOOK_ARGS = 6;
37
38 /**
39 * Container.
40 *
41 * @var \ContentControl\Base\Container
42 */
43 private $c;
44
45 /**
46 * Initialize license management.
47 *
48 * @param \ContentControl\Base\Container $c Container.
49 */
50 public function __construct( $c ) {
51 $this->c = $c;
52 }
53
54 /**
55 * Check if debug mode is enabled.
56 *
57 * @return bool
58 */
59 public function debug_mode_enabled() {
60 return defined( '\WP_DEBUG' ) && \WP_DEBUG;
61 }
62
63 /**
64 * Generate a new authorizatin token.
65 *
66 * @return string
67 */
68 public function generate_token() {
69 $token = hash( 'sha512', (string) wp_rand() );
70
71 \set_site_transient( self::TOKEN_OPTION_NAME, $token, HOUR_IN_SECONDS );
72
73 return $token;
74 }
75
76 /**
77 * Get the current token.
78 *
79 * @return string|false
80 */
81 public function get_access_token() {
82 return \get_site_transient( self::TOKEN_OPTION_NAME );
83 }
84
85 /**
86 * Get the current nonce.
87 *
88 * @param string $token Token.
89 *
90 * @return string|false
91 */
92 public function get_nonce_name( $token ) {
93 return self::NONCE_OPTION_NAME . '_' . $token;
94 }
95
96 /**
97 * Log a message to the debug log if enabled.
98 *
99 * Here to prevent constant conditional checks for the debug mode.
100 *
101 * @param string $message Message.
102 * @param string $type Type.
103 *
104 * @return void
105 */
106 public function debug_log( $message, $type = 'INFO' ) {
107 if ( $this->debug_mode_enabled() ) {
108 plugin( 'logging' )->log( "Plugin\Connect.$type: $message" );
109 }
110 }
111
112 /**
113 * Get header Authorization
114 *
115 * @return null|string
116 */
117 public function get_request_authorization_header() {
118 $headers = null;
119
120 if ( isset( $_SERVER['Authorization'] ) ) {
121 $headers = trim( sanitize_text_field( wp_unslash( $_SERVER['Authorization'] ) ) );
122 } elseif ( isset( $_SERVER['HTTP_AUTHORIZATION'] ) ) { // Nginx or fast CGI.
123 $headers = trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) ) );
124 } elseif ( function_exists( 'apache_request_headers' ) ) {
125 $request_headers = apache_request_headers();
126 // Server-side fix for bug in old Android versions (a nice side-effect of this fix means we don't care about capitalization for Authorization).
127 $request_headers = array_combine( array_map( 'ucwords', array_keys( $request_headers ) ), array_values( $request_headers ) );
128 if ( isset( $request_headers['Authorization'] ) ) {
129 $headers = trim( $request_headers['Authorization'] );
130 }
131 }
132
133 return $headers;
134 }
135
136 /**
137 * Get access token from header.
138 *
139 * @return string|null
140 */
141 public function get_request_token() {
142 $headers = $this->get_request_authorization_header();
143 // HEADER: Get the access token from the header.
144 if ( ! empty( $headers ) ) {
145 if ( preg_match( '/Bearer\s(\S+)/', $headers, $matches ) ) {
146 return trim( $matches[1], ', ' );
147 }
148 }
149 return null;
150 }
151
152 /**
153 * Get nonce from header.
154 *
155 * @return string|null
156 */
157 public function get_request_nonce() {
158 $headers = $this->get_request_authorization_header();
159 // HEADER: Get the nonce from the header.
160 if ( ! empty( $headers ) ) {
161 if ( preg_match( '/Nonce\s(\S+)/', $headers, $matches ) ) {
162 return trim( $matches[1], ', ' );
163 }
164 }
165 return null;
166 }
167
168 /**
169 * Get the OAuth connect URL.
170 *
171 * @param string $license_key License key.
172 *
173 * @return array{url:string,back_url:string}
174 */
175 public function get_connect_info( $license_key ) {
176 $token = $this->generate_token();
177 $nonce = wp_create_nonce( $this->get_nonce_name( $token ) );
178 $webhook = admin_url( 'admin-ajax.php' );
179 $redirect = add_query_arg(
180 [
181 'page' => 'content-control-settings',
182 'view' => 'upgrade',
183 'tab' => 'general',
184 ],
185 admin_url( 'options-general.php' )
186 );
187
188 $url = add_query_arg(
189 [
190 'key' => $license_key,
191 'token' => $token,
192 'nonce' => $nonce,
193 'webhook' => $webhook,
194 'version' => plugin( 'version' ),
195 'siteurl' => admin_url(),
196 'homeurl' => home_url(),
197 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
198 'redirect' => rawurldecode( base64_encode( $redirect ) ),
199 ],
200 self::API_URL
201 );
202
203 $this->debug_log( 'Generated new connection.' );
204 $this->debug_log( 'Token: ' . $token );
205 $this->debug_log( 'Nonce: ' . $nonce );
206
207 return [
208 'url' => $url,
209 'back_url' => add_query_arg(
210 [
211 'action' => 'content_control_connect',
212 'token' => $token,
213 ],
214 $webhook
215 ),
216 ];
217 }
218
219 /**
220 * Kill the connection with no permission.
221 *
222 * @param int $error_no Error number.
223 * @param string|false $message Error message.
224 *
225 * @return void
226 */
227 public function kill_connection( $error_no = self::ERROR_REFERRER, $message = false ) {
228 $this->debug_log( "Killing connection with error ($error_no) message: " . $message, 'ERROR' );
229
230 wp_die(
231 esc_html(
232 $this->debug_mode_enabled() && $message ?
233 $message :
234 __( 'Sorry, You Are Not Allowed to Access This Page.', 'content-control' )
235 ),
236 esc_attr( (string) $error_no ),
237 [ 'response' => 403 ]
238 );
239 }
240
241 /**
242 * Verify the user agent.
243 *
244 * @return void
245 */
246 public function verify_user_agent() {
247 // Check user agent matches Content Control Upgrader.
248 if ( ! isset( $_SERVER['HTTP_USER_AGENT'] ) || 'ContentControlUpgrader' !== $_SERVER['HTTP_USER_AGENT'] ) {
249 $this->kill_connection( self::ERROR_USER_AGENT, 'User agent invalid.' );
250 }
251 }
252
253 /**
254 * Verify the referrer.
255 *
256 * @return void
257 */
258 public function verify_referrer() {
259 $referer = isset( $_SERVER['HTTP_X_SENDING_DOMAIN'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_SENDING_DOMAIN'] ) ) : '';
260
261 if ( ! $referer ) {
262 // Mimic the default you don't have permission to do that screen from WordPress.
263 $this->kill_connection( self::ERROR_REFERRER, 'Missing referrer' );
264 }
265
266 $referer_host = wp_parse_url( $referer, PHP_URL_HOST );
267
268 if ( ! $referer_host ) {
269 $this->kill_connection( self::ERROR_REFERRER, 'Invalid referrer' );
270 }
271
272 $allowed_hosts = [
273 'contentcontrolplugin.com',
274 'upgrade.contentcontrolplugin.com',
275 ];
276
277 if ( ! in_array( $referer_host, $allowed_hosts, true ) ) {
278 $this->debug_log( 'Referrer mismatch: ' . $referer_host, 'DEBUG' );
279 $this->kill_connection( self::ERROR_REFERRER, 'Referrer doesn\'t match' );
280 }
281 }
282
283 /**
284 * Verify the nonce.
285 *
286 * @deprecated 2.0.0 Don't use, it doesn't work as its a separate server making request.
287 *
288 * @return void
289 */
290 public function verify_nonce() {
291 $token = $this->get_access_token();
292 $nonce = $this->get_request_nonce();
293
294 if ( ! $nonce ) {
295 $this->kill_connection( self::ERROR_NONCE, 'Missing nonce' );
296 }
297
298 if ( false === wp_verify_nonce( $nonce, $this->get_nonce_name( $token ) ) ) {
299 $this->debug_log( 'Nonce mismatch: ' . $nonce, 'DEBUG' );
300 $this->debug_log( 'Nonce Name: ' . $this->get_nonce_name( $token ) );
301 $this->kill_connection( self::ERROR_NONCE, 'Invalid nonce' );
302 }
303 }
304
305 /**
306 * Verify the authentication token.
307 *
308 * @return void
309 */
310 public function verify_authentication() {
311 // Get token from header Bearer.
312 $token = $this->get_access_token();
313 $auth_token = $this->get_request_token();
314
315 if ( ! $token || ! $auth_token ) {
316 $this->kill_connection( self::ERROR_AUTHENTICATION, 'Missing authentication' );
317 }
318
319 // Verify hashes match.
320 if ( ! hash_equals( $token, $auth_token ) ) {
321 $this->debug_log( 'Token mismatch: ' . $auth_token, 'DEBUG' );
322 $this->kill_connection( self::ERROR_AUTHENTICATION, 'Invalid authentiction' );
323 }
324 }
325
326 /**
327 * Generate signature hash.
328 *
329 * This must match the hash generated on the server.
330 *
331 * @see \ontentControlUpgrader\App::generate_hash()
332 *
333 * @param array<string,mixed>|string $data Data to hash.
334 * @param string $token Token to hash with.
335 * @return string
336 */
337 public function generate_hash( $data, $token ) {
338 // Convert boolean values to their string representation.
339 array_walk_recursive($data, function ( &$value ) {
340 if ( is_bool( $value ) ) {
341 $value = $value ? '1' : '0';
342 }
343 });
344
345 if ( ! is_string( $data ) ) {
346 // Sort the array before encoding it as JSON.
347 ksort( $data );
348
349 // Ignored due to wp_json_encode stripping slashes and breaking hashes.
350 // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
351 $data = json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
352 }
353
354 // Generate the hash binary.
355 $hash = hash_hmac( 'sha256', $data, $token, true );
356
357 // The only deviation from ServerSide is that we optionally log the hash if debug mode is enabled.
358 $this->debug_log( 'Hash: ' . $hash, 'DEBUG' );
359 $this->debug_log( 'Data: ' . $data, 'DEBUG' );
360
361 // Encode the hash in base64 to make it URL safe.
362 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
363 return base64_encode( $hash );
364 }
365
366 /**
367 * Verify the signature of the requester.
368 *
369 * @return void
370 */
371 public function verify_signature() {
372 if ( ! isset( $_SERVER['HTTP_X_CONTENTCONTROL_SIGNATURE'] ) ) {
373 return;
374 }
375
376 // Verify the webhook signature.
377 $signature = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_CONTENTCONTROL_SIGNATURE'] ) );
378
379 // Calculate the expected signature.
380 // phpcs:ignore WordPress.Security.NonceVerification.Missing
381 $expected_signature = $this->generate_hash( $_POST, $this->get_access_token() );
382
383 // Compare the expected signature to the received signature.
384 if ( ! hash_equals( $expected_signature, $signature ) ) {
385 $this->debug_log( "Signature mismatch: \r\n - Webhook: " . $signature . "\r\n - Calculated: " . $expected_signature, 'DEBUG' );
386 $this->kill_connection( self::ERROR_SIGNATURE, 'Invalid signature' );
387 }
388 }
389
390 /**
391 * Validate the connection.
392 *
393 * @return void
394 */
395 public function validate_connection() {
396 $this->debug_log( 'Validating connection...', 'DEBUG' );
397 $this->verify_user_agent();
398 // If production, verify the referrer.
399 if ( 'production' === wp_get_environment_type() ) {
400 $this->verify_referrer();
401 }
402 $this->verify_authentication();
403 $this->verify_signature();
404 $this->debug_log( 'Connection validated', 'DEBUG' );
405 }
406
407 /**
408 * Verify the connection.
409 *
410 * @return void
411 */
412 public function process_verify_connection() {
413 $this->validate_connection();
414 wp_send_json_success();
415 }
416
417 /**
418 * Get the webhook args.
419 *
420 * @return array{file:string,type:string,slug:string,force:boolean}
421 */
422 public function get_webhook_args() {
423 $args = [
424 // phpcs:disable WordPress.Security.NonceVerification.Recommended
425 'file' => ! empty( $_REQUEST['file'] ) ? esc_url_raw( wp_unslash( $_REQUEST['file'] ) ) : '',
426 'type' => ! empty( $_REQUEST['type'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['type'] ) ) : 'plugin',
427 'slug' => ! empty( $_REQUEST['slug'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['slug'] ) ) : '',
428 'force' => ! empty( $_REQUEST['force'] ) ? (bool) $_REQUEST['force'] : false,
429 // phpcs:enable WordPress.Security.NonceVerification.Recommended
430 ];
431
432 $this->verify_webhook_args( $args );
433
434 return $args;
435 }
436
437 /**
438 * Verify and return webhook args.
439 *
440 * @param array{file:string,type:string,slug:string,force:bool} $args The webhook args.
441 *
442 * @return void
443 */
444 public function verify_webhook_args( $args ) {
445 $file_url = ! empty( $args['file'] ) ? $args['file'] : false;
446 $file_type = ! empty( $args['type'] ) ? $args['type'] : false;
447 $file_slug = ! empty( $args['slug'] ) ? $args['slug'] : false;
448 $force = ! empty( $args['force'] ) ? (bool) $args['force'] : false;
449
450 if ( ! $file_url || ! $file_type || ! $file_slug ) {
451 $this->kill_connection( self::ERROR_WEBHOOK_ARGS, 'Missing webhook args' );
452 }
453
454 if ( ! in_array( $file_type, [ 'plugin', 'theme' ], true ) ) {
455 $this->kill_connection( self::ERROR_WEBHOOK_ARGS, 'Invalid webhook args' );
456 }
457 }
458
459 /**
460 * Listen for incoming secure webhooks from the API server.
461 *
462 * @return void
463 */
464 public function process_webhook() {
465 // 1. Validate the connection is secure & from the API server.
466 $this->validate_connection();
467
468 $error = esc_html__( 'There was an error while installing an upgrade. Please download the plugin from contentcontrolplugin.com and install it manually.', 'content-control' );
469
470 // 2. Get the webhook data.
471 $args = $this->get_webhook_args();
472
473 // 3. Validate license key.
474 if ( ! plugin( 'license' )->is_license_active() ) {
475 $this->debug_log( 'License not active', 'DEBUG' );
476 wp_send_json_error( $error );
477 }
478
479 // Set the current screen to avoid undefined notices.
480 set_current_screen( 'settings_page_content-control-settings' );
481
482 // 4. Install the plugin.
483 switch ( $args['type'] ) {
484 case 'plugin':
485 $this->install_plugin( $args );
486 break;
487 }
488
489 // 5. Delete the token to prevent abuse. Doing so last means it should be possible to retry if something goes wrong.
490 if ( ! $this->debug_mode_enabled() ) {
491 $this->debug_log( 'Deleting token', 'DEBUG' );
492 \delete_site_transient( self::TOKEN_OPTION_NAME );
493 }
494 }
495
496 /**
497 * Install a plugin.
498 *
499 * @param array{file:string,type:string,slug:string,force:bool} $args The file args.
500 * @return void
501 */
502 public function install_plugin( $args ) {
503 $this->debug_log( 'Installing plugin...', 'DEBUG' );
504
505 // If not forcing, and already active, return success.
506 if ( ! $args['force'] && is_plugin_active( "{$args['slug']}/{$args['slug']}.php" ) ) {
507 $this->debug_log( 'Plugin already installed & active.', 'DEBUG' );
508 wp_send_json_success( esc_html__( 'Plugin installed & activated.', 'content-control' ) );
509 }
510
511 // Get the upgrader.
512 $upgrader = $this->c->get( 'upgrader' );
513
514 // Install the plugin. (if installed already, this will replace it using upgrade).
515 $installed = $upgrader->install_plugin( $args['file'] );
516
517 if ( ! is_wp_error( $installed ) ) {
518 $this->debug_log( 'Plugin installed & activated successfully.', 'DEBUG' );
519 wp_send_json_success( esc_html__( 'Plugin installed & activated.', 'content-control' ) );
520 }
521
522 switch ( $installed->get_error_code() ) {
523 default:
524 $error = $installed->get_error_message();
525 }
526
527 if ( empty( $error ) ) {
528 $error = esc_html__( 'There was an error while installing an upgrade. Please download the plugin from contentcontrolplugin.com and install it manually.', 'content-control' );
529 }
530
531 $this->debug_log( 'Plugin install failed: ' . $error, 'DEBUG' );
532 wp_send_json_error( $installed->get_error_message() );
533 }
534 }
535