PluginProbe
Content Control – The Ultimate Content Restriction Plugin! Restrict Content, Create Conditional Blocks & More / 2.0.0
Content Control – The Ultimate Content Restriction Plugin! Restrict Content, Create Conditional Blocks & More v2.0.0
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.0.0, at classes/Plugin/Connect.php

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