PluginProbe
Patchstack – WordPress & Plugins Security / 2.3.7
Patchstack – WordPress & Plugins Security v2.3.7
2.3.7 trunk 2.1.0 2.1.1 2.1.10 2.1.11 2.1.12 2.1.13 2.1.14 2.1.15 2.1.16 2.1.17 2.1.18 2.1.19 2.1.2 2.1.20 2.1.21 2.1.22 2.1.23 2.1.24 2.1.25 2.1.3 2.1.4 2.1.5 2.1.6 All 49 releases
← All changes | includes/core.php +295 -115 2.1.212.3.7 View file →
@@ -22,9 +22,9 @@
22 22 * Whether or not the site is a multisite.
23 23 *
24 24 * @var boolean
25 25 */
26 - private $is_multi_site = false;
26 + public $is_multi_site = false;
27 27
28 28 /**
29 29 * Allowed HTML for the wp_kses function used to render certain paragraphs of texts.
30 30 *
@@ -29,89 +29,70 @@
29 29 * Allowed HTML for the wp_kses function used to render certain paragraphs of texts.
30 30 *
31 31 * @var array
32 32 */
33 - public $allowed_html = array(
34 - 'a' => array(
35 - 'href' => array(),
36 - 'title' => array(),
37 - 'target' => array()
38 - ),
39 - 'p' => array(
40 - 'style' => array()
41 - ),
42 - 'span' => array(
43 - 'style' => array()
44 - ),
45 - 'br' => array(),
46 - 'strong' => array(),
47 - 'b' => array(),
48 - 'i' => array(
49 - 'style' => array()
50 - ),
51 - 'label' => array(
52 - 'for' => array(),
53 - 'style' => array()
54 - ),
55 - 'input' => array(
56 - 'type' => array(),
57 - 'class' => array(),
58 - 'name' => array(),
59 - 'id' => array(),
60 - 'value' => array(),
61 - 'checked' => array(),
62 - 'style' => array()
63 - ),
64 - 'textarea' => array(
65 - 'rows' => array(),
66 - 'id' => array(),
67 - 'name' => array()
68 - ),
69 - 'select' => array(
70 - 'name' => array(),
71 - 'id' => array(),
72 - 'data-selected' => array()
73 - ),
74 - 'option' => array(
75 - 'value' => array(),
76 - 'selected' => array()
77 - ),
78 - 'table' => array(
79 - 'class' => array(),
80 - 'style' => array()
81 - ),
82 - 'thead' => array(),
83 - 'th' => array(
84 - 'style' => array()
85 - ),
86 - 'tr' => array(),
87 - 'td' => array(),
88 - 'div' => array(
89 - 'class' => array(),
90 - 'style' => array()
91 - )
92 - );
33 + public $allowed_html = [
34 + 'a' => [
35 + 'href' => [],
36 + 'title' => [],
37 + 'target' => []
38 + ],
39 + 'p' => [
40 + 'style' => []
41 + ],
42 + 'span' => [
43 + 'style' => []
44 + ],
45 + 'br' => [],
46 + 'strong' => [],
47 + 'b' => [],
48 + 'i' => [
49 + 'style' => []
50 + ],
51 + 'label' => [
52 + 'for' => [],
53 + 'style' => []
54 + ],
55 + 'input' => [
56 + 'type' => [],
57 + 'class' => [],
58 + 'name' => [],
59 + 'id' => [],
60 + 'value' => [],
61 + 'checked' => [],
62 + 'style' => []
63 + ],
64 + 'textarea' => [
65 + 'rows' => [],
66 + 'id' => [],
67 + 'name' => []
68 + ],
69 + 'select' => [
70 + 'name' => [],
71 + 'id' => [],
72 + 'data-selected' => []
73 + ],
74 + 'option' => [
75 + 'value' => [],
76 + 'selected' => []
77 + ],
78 + 'table' => [
79 + 'class' => [],
80 + 'style' => []
81 + ],
82 + 'thead' => [],
83 + 'th' => [
84 + 'style' => []
85 + ],
86 + 'tr' => [],
87 + 'td' => [],
88 + 'div' => [
89 + 'class' => [],
90 + 'style' => []
91 + ]
92 + ];
93 93
94 94 /**
95 - * Some of the IP addresses of Patchstack.
96 - *
97 - * @var array
98 - */
99 - public $ips = array(
100 - '18.221.197.243',
101 - '52.15.237.250',
102 - '3.19.3.34',
103 - '3.18.238.17',
104 - '13.58.49.77',
105 - '18.222.191.77',
106 - '3.131.108.250',
107 - '3.23.157.140',
108 - '18.220.70.233',
109 - '3.140.84.221',
110 - '185.212.171.100'
111 - );
112 -
113 - /**
114 95 * @param Patchstack $plugin
115 96 * @return void
116 97 */
117 98 public function __construct( $plugin ) {
@@ -128,9 +109,9 @@
128 109 * @return mixed
129 110 */
130 111 public function get_option( $name, $default = false ) {
131 112 // We always want to return the site option on the default settings management page.
132 - if ( isset( $_GET['page'] ) && $_GET['page'] == 'patchstack-multisite-settings' && is_super_admin() ) {
113 + if ( isset( $_GET['page'] ) && $_GET['page'] == 'patchstack-multisite-settings' && function_exists( 'wp_get_current_user' ) && is_super_admin() ) {
133 114 return get_site_option( $name, $default );
134 115 }
135 116
136 117 // Get the setting of the current site.
@@ -135,8 +116,14 @@
135 116
136 117 // Get the setting of the current site.
137 118 $secondary = get_option( $name, $default );
138 119
120 + // On single-site installs there is no network option to reconcile, so
121 + // avoid the extra get_site_option() lookup on every read.
122 + if ( ! is_multisite() ) {
123 + return $secondary;
124 + }
125 +
139 126 // Get the setting of the network and in case there's a difference,
140 127 // return the value of site.
141 128 $main = get_site_option( $name, $default );
142 129 return $main != $secondary ? $secondary : $main;
@@ -186,9 +173,9 @@
186 173 return true;
187 174 }
188 175
189 176 $expiry = get_option( 'patchstack_license_expiry', '' );
190 - if ( $expiry != '' && ( strtotime( $expiry ) < ( time() + ( 3600 * 24 ) ) ) ) {
177 + if ( $expiry != '' && ( strtotime( $expiry ) > ( time() - ( 3600 * 24 ) ) ) ) {
191 178 return true;
192 179 }
193 180
194 181 return false;
@@ -194,69 +181,262 @@
194 181 return false;
195 182 }
196 183
197 184 /**
198 - * Determine if a given PHP function is disabled or not.
199 - *
200 - * @param string $name Name of the function to check.
201 - * @return boolean Whether or not the function is available to call.
185 + * Determine if the plugin is connected to the API.
186 + *
187 + * @return boolean
202 188 */
203 - public function function_available( $name ) {
204 - $safe_mode = ini_get( 'safe_mode' );
205 - if ( $safe_mode && strtolower( $safe_mode ) != 'off' ) {
189 + public function is_connected() {
190 + // Determine if the API client id is set.
191 + if ( $this->plugin->client_id == 'PATCHSTACK_CLIENT_ID' && ! get_option( 'patchstack_clientid' ) ) {
206 192 return false;
207 193 }
208 194
209 - // Determine if the function is available.
210 - if ( in_array( $name, array_map( 'trim', explode( ',', ini_get( 'disable_functions' ) ) ) ) ) {
195 + // Determine if we have an API token.
196 + if ( get_option( 'patchstack_api_token', '' ) == '' ) {
211 197 return false;
212 198 }
213 199
200 + // Determine if we have a last license check set.
201 + $last_license_check = get_option( 'patchstack_last_license_check', 0 );
202 + if ( !empty( $last_license_check ) && time() - $last_license_check >= 604800 ) {
203 + return false;
204 + }
205 +
214 206 return true;
215 207 }
216 208
217 209 /**
218 - * Attempt to get the client IP by checking all possible IP (proxy) headers.
210 + * Determine if the plugin provides protection.
211 + *
212 + * @return boolean
213 + */
214 + public function is_protected() {
215 + return (int) get_option( 'patchstack_license_free', 0 ) == 0;
216 + }
217 +
218 + /**
219 + * Format a UNIX timestamp as a short relative-time string for the connection card.
220 + * Returns "Never" for empty/zero, otherwise "Just now" / "Xm ago" / "Xh ago" / "Xd ago".
219 221 *
222 + * The returned string is the raw translated value — escape it at the call site.
223 + *
224 + * @param int $timestamp UNIX timestamp.
225 + * @return string Translated relative-time label (not escaped).
226 + */
227 + public function format_relative_time( $timestamp ) {
228 + $timestamp = (int) $timestamp;
229 + if ( $timestamp <= 0 ) {
230 + return __( 'Never', 'patchstack' );
231 + }
232 +
233 + $diff = time() - $timestamp;
234 + if ( $diff < 60 ) {
235 + return __( 'Just now', 'patchstack' );
236 + }
237 + if ( $diff < 3600 ) {
238 + /* translators: %d: number of minutes since the last sync. */
239 + return sprintf( __( '%dm ago', 'patchstack' ), (int) floor( $diff / 60 ) );
240 + }
241 + if ( $diff < 86400 ) {
242 + /* translators: %d: number of hours since the last sync. */
243 + return sprintf( __( '%dh ago', 'patchstack' ), (int) floor( $diff / 3600 ) );
244 + }
245 + /* translators: %d: number of days since the last sync. */
246 + return sprintf( __( '%dd ago', 'patchstack' ), (int) floor( $diff / 86400 ) );
247 + }
248 +
249 + /**
250 + * Get the timestamp of the last successful API sync.
251 + *
252 + * Prefers patchstack_last_sync, which is stamped on every successful (200 OK)
253 + * API request (log/software uploads, rule pulls, license verify, ping, etc.),
254 + * so it reflects real sync activity rather than only license verification.
255 + * Falls back to patchstack_last_license_check for sites that have not synced
256 + * yet since this option was introduced.
257 + *
258 + * @return int UNIX timestamp, or 0 if never synced.
259 + */
260 + public function get_last_sync_time() {
261 + $last_sync = (int) get_option( 'patchstack_last_sync', 0 );
262 + if ( $last_sync > 0 ) {
263 + return $last_sync;
264 + }
265 +
266 + return (int) get_option( 'patchstack_last_license_check', 0 );
267 + }
268 +
269 + /**
270 + * Grab the IP address of the user. Give the override IP header priority.
271 + * If this does not exist, we should always default to REMOTE_ADDR.
272 + *
220 273 * @return string
221 274 */
222 275 public function get_ip() {
223 - // IP address header override set?
224 - $override = get_site_option( 'patchstack_firewall_ip_header', '' );
276 + $override = get_option( 'patchstack_firewall_ip_header', '' );
225 277 if ( $override != '' && isset( $_SERVER[ $override ] ) ) {
226 278 return $_SERVER[ $override ];
227 279 }
228 280
229 - // IP address headers which should have priority and be used regardless of other headers.
230 - $priority = array( 'HTTP_CF_CONNECTING_IP', 'HTTP_X_SUCURI_CLIENTIP' );
231 - foreach ( $priority as $header ) {
232 - if ( isset( $_SERVER[ $header ] ) && filter_var( $_SERVER[ $header ], FILTER_VALIDATE_IP ) !== false ) {
233 - return $_SERVER[ $header ];
234 - }
281 + return isset( $_SERVER['REMOTE_ADDR'] ) ? $_SERVER['REMOTE_ADDR'] : '';
282 + }
283 +
284 + /**
285 + * Grab the secret key used for API communication.
286 + *
287 + * @param string $custom
288 + * @return string
289 + */
290 + public function get_secret_key( $custom = '' ) {
291 + if ( $custom != '' ) {
292 + return $this->encrypt( $custom );
235 293 }
236 294
237 - // Special case for hosts that have a weird configuration.
238 - if ( $this->function_available( 'php_uname' ) ) {
239 - $uname = @php_uname();
295 + $secret = get_option( 'patchstack_secretkey', '' );
296 + if ( ! $secret ) {
297 + return '';
298 + }
240 299
241 - // Bluehos and Hostmonster store the real IP in $_SERVER['REMOTE_ADDR'] but the proxy IP in HTTP_X_FORWARDED_FOR.t
242 - if ( strpos( $uname, 'bluehost' ) !== false || strpos( $uname, 'hostmonster' ) !== false ) {
243 - return $_SERVER['REMOTE_ADDR'];
300 + if ( strlen( $secret ) === 40 ) {
301 + $enc = $this->encrypt( $secret );
302 +
303 + update_option( 'patchstack_secretkey', $enc['cipher'] );
304 + update_option( 'patchstack_secretkey_nonce', $enc['nonce'] );
305 +
306 + return $secret;
307 + }
308 +
309 + $nonce = get_option( 'patchstack_secretkey_nonce' );
310 + return $this->decrypt( $secret, $nonce );
311 + }
312 +
313 + /**
314 + * Set the secret key used for API communication.
315 + *
316 + * @param string $secret
317 + * @return void
318 + */
319 + public function set_secret_key( $secret ) {
320 + $enc = $this->encrypt( $secret );
321 +
322 + update_option( 'patchstack_secretkey', $enc['cipher'] );
323 + update_option( 'patchstack_secretkey_nonce', $enc['nonce'] );
324 + }
325 +
326 + /**
327 + * Determine which encryption dependency we can use.
328 + *
329 + * @return string
330 + */
331 + public function get_enc_type() {
332 + if ( function_exists('sodium_crypto_generichash') ) {
333 + return 'native';
334 + }
335 +
336 + return 'compat';
337 + }
338 +
339 + /**
340 + * Get the unique nonce that is used for the secretbox.
341 + *
342 + * @return string
343 + */
344 + public function get_enc_nonce() {
345 + if ( function_exists('random_bytes') ) {
346 + return random_bytes( 24 );
347 + }
348 +
349 + require_once dirname( __FILE__ ) . '/2fa/polyfill/lib/random.php';
350 + return random_bytes( 24 );
351 + }
352 +
353 + /**
354 + * Encrypt a string.
355 + *
356 + * @param string $message
357 + * @return array
358 + */
359 + public function encrypt( $message ) {
360 + if ( is_null( $message ) || ! defined( 'AUTH_KEY' ) ) {
361 + return [
362 + 'cipher' => $message,
363 + 'nonce' => ''
364 + ];
365 + }
366 +
367 + $enc_type = $this->get_enc_type();
368 + $nonce = $this->get_enc_nonce();
369 +
370 + try {
371 + // Use the PHP native encryption functions.
372 + if ( $enc_type == 'native' ) {
373 + $key = sodium_crypto_generichash( AUTH_KEY );
374 +
375 + return [
376 + 'cipher' => sodium_bin2hex( sodium_crypto_secretbox( $message, $nonce, $key ) ),
377 + 'nonce' => sodium_bin2hex( $nonce )
378 + ];
244 379 }
245 380
246 - // Hostgator stores the real IP in $_SERVER['REMOTE_ADDR'] but the proxy IP in HTTP_X_FORWARDED_FOR.
247 - if ( ( strpos( $uname, 'websitewelcome' ) || strpos( $uname, 'hostgator' ) ) && isset( $_SERVER['HTTP_X_FORWARDED_FOR'] ) && $_SERVER['HTTP_X_FORWARDED_FOR'] != $_SERVER['REMOTE_ADDR'] ) {
248 - return $_SERVER['REMOTE_ADDR'];
381 + // Use the Sodium polyfill library part of WordPress core.
382 + if ( ! file_exists( ABSPATH . WPINC . '/sodium_compat/autoload.php' ) ) {
383 + return [
384 + 'cipher' => $message,
385 + 'nonce' => ''
386 + ];
249 387 }
388 + require_once ABSPATH . WPINC . '/sodium_compat/autoload.php';
389 + $key = \Sodium\crypto_generichash( AUTH_KEY );
390 +
391 + return [
392 + 'cipher' => \Sodium\bin2hex( \Sodium\crypto_secretbox( $message, $nonce, $key ) ),
393 + 'nonce' => \Sodium\bin2hex( $nonce )
394 + ];
395 + } catch ( Exception $e ) {
396 + return [
397 + 'cipher' => $message,
398 + 'nonce' => ''
399 + ];
250 400 }
401 + }
251 402
252 - // In order of priority, try to get the IP address.
253 - $allowed = array( 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED', 'HTTP_FORWARDED_FOR', 'HTTP_FORWARDED', 'SUCURI_RIP', 'REMOTE_ADDR' );
254 - foreach ( $allowed as $header ) {
255 - if ( isset( $_SERVER[ $header ] ) && filter_var( $_SERVER[ $header ], FILTER_VALIDATE_IP ) !== false ) {
256 - return $_SERVER[ $header ];
403 + /**
404 + * Decrypt a cipher to plain-text.
405 + *
406 + * @param string $cipher
407 + * @param string $nonce
408 + * @return string
409 + */
410 + public function decrypt( $cipher, $nonce ) {
411 + $enc_type = $this->get_enc_type();
412 +
413 + // If we received an empty nonce, we assume it was never properly encrypted to begin with.
414 + if ( $nonce == '' || ! defined( 'AUTH_KEY' ) ) {
415 + return $cipher;
416 + }
417 +
418 + try {
419 + // Determine if we should use native or polyfill functions.
420 + if ( $enc_type == 'native' ) {
421 + $key = sodium_crypto_generichash( AUTH_KEY );
422 + $dec = sodium_crypto_secretbox_open( sodium_hex2bin( $cipher ), sodium_hex2bin( $nonce ), $key );
423 + } else {
424 + if ( ! file_exists( ABSPATH . WPINC . '/sodium_compat/autoload.php' ) ) {
425 + return $cipher;
426 + }
427 + require_once ABSPATH . WPINC . '/sodium_compat/autoload.php';
428 + $key = \Sodium\crypto_generichash( AUTH_KEY );
429 + $dec = \Sodium\crypto_secretbox_open( sodium_hex2bin( $cipher ), sodium_hex2bin( $nonce ), $key );
257 430 }
431 + } catch ( Exception $e ) {
432 + return $cipher;
258 433 }
259 434
260 - return '127.0.0.1';
435 + // In case decryption failed, return null.
436 + if ( ! $dec ) {
437 + return null;
438 + }
439 +
440 + return $dec;
261 441 }
262 442 }