PluginProbe
DoLogin Security / trunk
DoLogin Security vtrunk
5.0.10 4.8.3 trunk 1.0 1.1 1.1.1 1.2 1.2.1 1.2.2 1.3 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 1.4 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.5 1.6 All 64 releases
dologin / src / site.cls.php

site.cls.php in DoLogin Security trunk, at src/site.cls.php

757 lines 27.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Child Site one click connection class
4 *
5 * @since 4.0
6 * @package dologin
7 */
8
9 namespace dologin;
10
11 defined( 'WPINC' ) || exit;
12
13 class Site extends Instance {
14
15 const TYPE_GEN_TOKEN = 'gen_token';
16 const TYPE_CONNECT = 'connect_site';
17 const TYPE_AUTH = 'auth';
18 const TYPE_EASY_LOGIN = 'easy_login'; // Login to child site
19 const TYPE_LOCK = 'lock';
20 const TYPE_DEL = 'del';
21 const QS_NAME_ROOT_AUTH = 'dologin_root_auth';
22 const QS_NAME_EASY_LOGIN = 'dologin_easy_login';
23
24 private $_tb;
25
26 protected function __construct() {
27 $this->_tb = $this->cls( 'Data' )->tb( 'site' );
28 }
29
30 /**
31 * Init
32 *
33 * @since 4.0
34 */
35 public function init() {
36 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing
37 if ( ! empty( $_GET[ self::QS_NAME_ROOT_AUTH ] ) && ! empty( $_POST['pk'] ) ) {
38 defined( 'debug' ) && debug( 'knock knock, site connection in' );
39 add_action( 'init', array( $this, 'connect_auth_init' ) );
40 }
41 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
42 if ( ! empty( $_GET[ self::QS_NAME_EASY_LOGIN ] ) ) {
43 defined( 'debug' ) && debug( 'knock knock, easy login comes' );
44 add_action( 'init', array( $this, 'try_easy_login' ) );
45 }
46 }
47
48 /**
49 * Check cryptographic requirements for site connections.
50 *
51 * @since 4.6.5
52 */
53 private function _sodium_ready() {
54 return KLSso::sodium_ready()
55 && function_exists( 'sodium_crypto_sign' )
56 && function_exists( 'sodium_crypto_sign_open' );
57 }
58
59 /**
60 * Easy login to child site init and jump
61 *
62 * @since 4.0
63 */
64 private function _easy_login() {
65 global $wpdb;
66 if ( ! $this->_sodium_ready() ) {
67 return $this->_admin_error( 'dologin_sodium_unavailable' );
68 }
69
70 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
71 $pid = empty( $_GET['dologin_id'] ) ? 0 : (int) $_GET['dologin_id'];
72 if ( $pid <= 0 ) {
73 return;
74 }
75 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery --$this->_tb is a hardcoded internal table name; id is prepared.
76 $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM `$this->_tb` WHERE id = %d", $pid ) );
77 if ( ! $row || 1 !== (int) $row->active || 0 !== (int) $row->is_child || ! wp_http_validate_url( $row->url ) ) {
78 return $this->_admin_error( 'dologin_site_token_invalid' );
79 }
80
81 $audience = $this->_easy_login_audience( $row->url );
82 try {
83 $jti = bin2hex( random_bytes( 16 ) );
84 } catch ( \Exception $ex ) {
85 return $this->_admin_error( 'dologin_token_generation_failed' );
86 }
87 $claims = array(
88 'v' => 2,
89 'uid' => (int) $row->user_id,
90 'pk' => (string) Conf::val( '_pk' ),
91 'aud' => $audience,
92 'iat' => time(),
93 'jti' => $jti,
94 );
95 $claims_json = $this->_easy_login_claims_json( $claims );
96 $signature = $claims_json ? $this->_pack_b64sign( $claims_json ) : false;
97 if ( ! $audience || ! $claims_json || ! $signature ) {
98 return $this->_admin_error( 'dologin_token_generation_failed' );
99 }
100 $data = wp_json_encode(
101 array(
102 'claims' => $claims,
103 'sig' => $signature,
104 )
105 );
106 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- benign base64 encoding of the easy-login token payload.
107 $url = add_query_arg( self::QS_NAME_EASY_LOGIN, base64_encode( $data ), $row->url );
108 defined( 'debug' ) && debug( 'Easy login token generated for child site.' );
109 // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- intentional cross-site redirect to the connected child site.
110 wp_redirect( $url );
111 exit();
112 }
113
114 /**
115 * Allow a connection from a root site
116 *
117 * @since 4.0
118 */
119 public function try_easy_login() {
120 global $wpdb;
121
122 $username = 'N/A';
123
124 // This endpoint bypasses wp-login and must apply the same IP rules and failure limits.
125 if ( $this->cls( 'Auth' )->is_ip_denied() ) {
126 $this->_error_page( 'dologin_ip_denied', 403 );
127 }
128 if ( $this->cls( 'Auth' )->is_rate_limited() ) {
129 $this->_error_page( 'dologin_rate_limited', 429 );
130 }
131 if ( ! $this->_sodium_ready() ) {
132 $this->_error_page( 'dologin_sodium_unavailable', 503 );
133 }
134
135 // Magic-link endpoint authenticated by the signed token, not a nonce.
136 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
137 $raw_token = isset( $_GET[ self::QS_NAME_EASY_LOGIN ] ) && is_string( $_GET[ self::QS_NAME_EASY_LOGIN ] ) ? sanitize_text_field( wp_unslash( $_GET[ self::QS_NAME_EASY_LOGIN ] ) ) : '';
138 if ( KLSso::force_enabled() ) {
139 $this->_error_page( 'dologin_kl_sso_required', 403 );
140 }
141 $token = $this->_decode_easy_login_token( $raw_token );
142 if ( ! $token ) {
143 defined( 'debug' ) && debug( 'dologin easy login token failed to decode' );
144 return $this->_failed_login( $username );
145 }
146
147 $claims = $token['claims'];
148 $uid = $claims['uid'];
149 $pk = $claims['pk'];
150
151 // Validate root site record FIRST; the public key must match a stored, trusted connection.
152 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery --$this->_tb is a hardcoded internal table name; values are prepared.
153 $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM `$this->_tb` WHERE user_id=%d AND pk=%s", $uid, $pk ) );
154 if ( ! $row ) {
155 defined( 'debug' ) && debug( 'dologin easy login no record found for uid: ' . $uid . ', pk: ' . $pk );
156 return $this->_failed_login( $username );
157 }
158 if ( $row->active != 1 || $row->is_child != 1 ) {
159 $this->_error_page( 'dologin_invalid_root_record', 403 );
160 }
161
162 // Verify the complete assertion against the stored public key, never a request-only key.
163 $signed_claims = $this->_unpack_b64sign( $token['sig'], $row->pk );
164 if ( ! is_string( $signed_claims ) || ! hash_equals( $token['claims_json'], $signed_claims ) ) {
165 defined( 'debug' ) && debug( 'dologin easy login token invalid' );
166 return $this->_failed_login( $username );
167 }
168 $audience = $this->_easy_login_audience( admin_url() );
169 if ( ! $audience || ! hash_equals( $audience, $claims['aud'] ) ) {
170 defined( 'debug' ) && debug( 'dologin easy login token audience mismatch' );
171 return $this->_failed_login( $username );
172 }
173 $ts = $claims['iat'];
174 $now = time();
175 if ( $ts < $now - 3600 || $ts > $now + 300 ) { // Tokens cannot be older than one hour or materially in the future.
176 defined( 'debug' ) && debug( 'dologin easy login token expired. Got ts: ' . $ts . ', current: ' . $now );
177 return $this->_failed_login( $username );
178 }
179 // Only a token newer than every previously consumed token may proceed.
180 if ( (int) $row->last_used_at >= $ts ) {
181 defined( 'debug' ) && debug( 'dologin easy login already used' . $ts );
182 $this->_error_page( 'dologin_link_used', 410 );
183 }
184
185 $user_info = get_userdata( $uid );
186 if ( ! $user_info ) {
187 return $this->_failed_login( $username );
188 }
189 $username = $user_info->user_login;
190 defined( 'debug' ) && debug( 'dologin easy login passed, uid: ' . $uid . ', username: ' . $user_info->user_login );
191
192 $confirm_nonce_action = 'dologin_easy_login_confirm_' . hash( 'sha256', $raw_token );
193
194 // Show login confirm page.
195 // phpcs:ignore WordPress.Security.NonceVerification.Missing
196 if ( empty( $_POST['confirmed'] ) ) {
197 require_once DOLOGIN_DIR . 'tpl/easylogin_cfm.tpl.php';
198 exit;
199 }
200
201 // phpcs:ignore WordPress.Security.NonceVerification.Missing
202 $confirm_nonce = empty( $_POST['dologin_confirm_nonce'] ) || ! is_string( $_POST['dologin_confirm_nonce'] ) ? '' : sanitize_text_field( wp_unslash( $_POST['dologin_confirm_nonce'] ) );
203 if ( ! wp_verify_nonce( $confirm_nonce, $confirm_nonce_action ) ) {
204 return $this->_failed_login( $username );
205 }
206
207 // Can login, update record first.
208 $q = "UPDATE `$this->_tb` SET last_used_at=%d, count=count+1 WHERE id=%d AND active=1 AND last_used_at<%d";
209 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery --$this->_tb is a hardcoded internal table name; values are prepared.
210 $updated = $wpdb->query( $wpdb->prepare( $q, array( $ts, $row->id, $ts ) ) );
211 if ( 1 !== $updated ) {
212 $this->_error_page( 'dologin_link_used', 410 );
213 }
214
215 // Login.
216 wp_set_current_user( $user_info->ID );
217 wp_set_auth_cookie( $user_info->ID, false );
218 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- firing the WordPress core hook on programmatic login.
219 do_action( 'wp_login', $user_info->user_login, $user_info );
220
221 nocache_headers();
222
223 Router::redirect( admin_url() );
224 }
225
226 /**
227 * Build the canonical easy-login assertion string.
228 */
229 private function _easy_login_claims_json( $claims ) {
230 if ( ! is_array( $claims ) ) {
231 return false;
232 }
233 $required = array( 'v', 'uid', 'pk', 'aud', 'iat', 'jti' );
234 $keys = array_keys( $claims );
235 sort( $keys, SORT_STRING );
236 $sorted_required = $required;
237 sort( $sorted_required, SORT_STRING );
238 if ( $keys !== $sorted_required
239 || ! is_int( $claims['v'] ) || 2 !== $claims['v']
240 || ! is_int( $claims['uid'] ) || $claims['uid'] <= 0
241 || ! is_string( $claims['pk'] ) || '' === $claims['pk']
242 || ! is_string( $claims['aud'] ) || '' === $claims['aud']
243 || ! is_int( $claims['iat'] ) || $claims['iat'] <= 0
244 || ! is_string( $claims['jti'] ) || ! preg_match( '/^[a-f0-9]{32}$/D', $claims['jti'] ) ) {
245 return false;
246 }
247
248 return wp_json_encode(
249 array(
250 'v' => 2,
251 'uid' => $claims['uid'],
252 'pk' => $claims['pk'],
253 'aud' => $claims['aud'],
254 'iat' => $claims['iat'],
255 'jti' => $claims['jti'],
256 )
257 );
258 }
259
260 /**
261 * Decode and strictly validate a versioned easy-login token.
262 */
263 private function _decode_easy_login_token( $raw_token ) {
264 if ( ! is_string( $raw_token ) || '' === $raw_token || strlen( $raw_token ) > 16384 ) {
265 return false;
266 }
267 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- benign base64 decoding of the easy-login token payload.
268 $decoded = base64_decode( $raw_token, true );
269 $token = false === $decoded ? null : json_decode( $decoded, true );
270 if ( ! is_array( $token ) || array( 'claims', 'sig' ) !== array_keys( $token ) || ! is_array( $token['claims'] ) || ! is_string( $token['sig'] ) || '' === $token['sig'] ) {
271 return false;
272 }
273 $claims_json = $this->_easy_login_claims_json( $token['claims'] );
274 if ( ! $claims_json ) {
275 return false;
276 }
277 $token['claims_json'] = $claims_json;
278 return $token;
279 }
280
281 /**
282 * Normalize the target admin URL used as the signed token audience.
283 */
284 private function _easy_login_audience( $url ) {
285 $url = is_string( $url ) ? esc_url_raw( $url ) : '';
286 if ( ! $url || ! wp_http_validate_url( $url ) ) {
287 return '';
288 }
289 $parts = wp_parse_url( $url );
290 if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) || isset( $parts['user'] ) || isset( $parts['pass'] ) ) {
291 return '';
292 }
293 $scheme = strtolower( $parts['scheme'] );
294 if ( ! in_array( $scheme, array( 'http', 'https' ), true ) ) {
295 return '';
296 }
297 $host = strtolower( $parts['host'] );
298 $port = isset( $parts['port'] ) ? ':' . (int) $parts['port'] : '';
299 $path = isset( $parts['path'] ) ? '/' . trim( $parts['path'], '/' ) . '/' : '/';
300 return $scheme . '://' . $host . $port . $path;
301 }
302
303 /**
304 * Note failed login
305 *
306 * @since 4.0
307 */
308 private function _failed_login( $username ) {
309 defined( 'debug' ) && debug( 'Failed to auth as user: ', $username );
310 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- firing the WordPress core hook.
311 do_action( 'wp_login_failed', $username );
312 $this->_error_page( 'dologin_link_invalid', 403 );
313 }
314
315 /**
316 * Complete a rejected public token request with a localized page.
317 */
318 private function _error_page( $tag, $status_code ) {
319 GUI::error_page( $tag, $status_code );
320 exit;
321 }
322
323 /**
324 * Queue a localized error for a nonce- and capability-gated admin action.
325 */
326 private function _admin_error( $tag ) {
327 GUI::error( Lang::msg( $tag ) );
328 }
329
330 /**
331 * Lock
332 *
333 * @since 4.0
334 */
335 private function _lock_link() {
336 global $wpdb;
337
338 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
339 $pid = empty( $_GET['dologin_id'] ) ? 0 : (int) $_GET['dologin_id'];
340 if ( $pid <= 0 ) {
341 return;
342 }
343
344 $q = "UPDATE `$this->_tb` SET active = ( active + 1 ) % 2 WHERE id = %d";
345 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery --$this->_tb is a hardcoded internal table name; id is prepared.
346 $wpdb->query( $wpdb->prepare( $q, $pid ) );
347 }
348
349 /**
350 * Delete
351 *
352 * @since 4.0
353 */
354 public function del_link( $pid = false ) {
355 global $wpdb;
356
357 if ( ! $pid ) {
358 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
359 if ( empty( $_GET['dologin_id'] ) ) {
360 return;
361 }
362
363 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
364 $pid = (int) $_GET['dologin_id'];
365 }
366
367 $pid = (int) $pid;
368 if ( $pid <= 0 ) {
369 return;
370 }
371
372 $q = "DELETE FROM `$this->_tb` WHERE id = %d";
373 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery --$this->_tb is a hardcoded internal table name; id is prepared.
374 $wpdb->query( $wpdb->prepare( $q, $pid ) );
375 }
376
377 /**
378 * Generate a new connection token
379 *
380 * @since 4.0
381 * @access public
382 */
383 public function gen_token( $uid, $return_url = false ) {
384 global $wpdb;
385 if ( ! $this->_sodium_ready() ) {
386 if ( $return_url ) {
387 return 'Sodium cryptography support is required';
388 }
389 GUI::error( __( 'Sodium cryptography support is required.', 'dologin' ) );
390 Router::redirect( admin_url( 'options-general.php?page=dologin' ) );
391 }
392
393 $this->cls( 'Data' )->tb_create( 'site' );
394
395 $uid = (int) $uid;
396 $user_info = $uid > 0 ? get_userdata( $uid ) : false;
397 if ( ! $user_info ) {
398 if ( $return_url ) {
399 return 'Invalid User ID';
400 }
401 Router::redirect( admin_url( 'options-general.php?page=dologin' ) );
402 }
403
404 $token = s::rrand( 32 );
405 $hash = Secret::token_hash( 'site-connection', $token );
406 if ( ! $hash ) {
407 if ( $return_url ) {
408 return false;
409 }
410 wp_die( esc_html__( 'Failed to protect the site connection token.', 'dologin' ) );
411 }
412
413 $q = "INSERT INTO `$this->_tb` SET user_id = %d, user_name = %s, hash = %s, dateline = %d, active=1,is_child=1";
414 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery --$this->_tb is a hardcoded internal table name; values are prepared.
415 $inserted = $wpdb->query( $wpdb->prepare( $q, array( $uid, $user_info->user_login, $hash, time() ) ) );
416 $id = $wpdb->insert_id;
417 if ( 1 !== $inserted || $id <= 0 ) {
418 if ( $return_url ) {
419 return false;
420 }
421 wp_die( esc_html__( 'Failed to save the site connection token.', 'dologin' ) );
422 }
423
424 $link = admin_url( '?' . self::QS_NAME_ROOT_AUTH . '=' . $id . '.' . $token );
425 if ( $return_url ) {
426 return $link;
427 }
428
429 $this->show_generated_connection_token( base64_encode( $link ) );
430 }
431
432 /**
433 * Display a newly generated connection token once without storing its raw secret.
434 */
435 private function show_generated_connection_token( $token ) {
436 $back = admin_url( 'options-general.php?page=dologin' );
437 $message = '<p>' . esc_html__( 'Copy this child-site connection token now. For database-leak protection, its secret value is not stored and cannot be shown again.', 'dologin' ) . '</p>'
438 . '<p><code style="display:block;overflow-wrap:anywhere;padding:12px;">' . esc_html( $token ) . '</code></p>'
439 . '<p><a class="button button-primary" href="' . esc_url( $back ) . '">' . esc_html__( 'Continue to Site Connections', 'dologin' ) . '</a></p>';
440 wp_die( $message, esc_html__( 'Site Connection Token Created', 'dologin' ), array( 'response' => 200 ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- every dynamic value in the assembled admin-only message is escaped above.
441 }
442
443 /**
444 * Init PK/SK
445 *
446 * @since 4.0
447 */
448 private function _init_pksk() {
449 if ( ! $this->_sodium_ready() ) {
450 return false;
451 }
452 if ( Conf::val( '_pk' ) && Conf::val( '_sk' ) ) {
453 return false;
454 }
455
456 defined( 'debug' ) && debug( 'Generate new PK/SK' );
457
458 $keypair = sodium_crypto_sign_keypair();
459 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- benign base64 encoding of an ed25519 public key.
460 $pk = base64_encode( sodium_crypto_sign_publickey( $keypair ) );
461 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- benign base64 encoding of an ed25519 secret key.
462 $sk = base64_encode( sodium_crypto_sign_secretkey( $keypair ) );
463 $sealed = Secret::seal( 'site-easy-login-signing-key', $sk );
464 if ( ! $sealed ) {
465 return false;
466 }
467 Conf::update( '_pk', $pk );
468 Conf::update( '_sk', $sealed );
469 return true;
470 }
471
472 /**
473 * Sign a msg w/ SK
474 *
475 * @since 4.0
476 * @access public
477 */
478 private function _pack_b64sign( $msg ) {
479 $pk = Conf::val( '_pk' );
480 $stored_sk = Conf::val( '_sk' );
481 if ( ! $pk || ! $stored_sk || ! $this->_sodium_ready() ) {
482 return false;
483 }
484 if ( Secret::is_sealed( $stored_sk ) ) {
485 $sk = Secret::open( 'site-easy-login-signing-key', $stored_sk );
486 } else {
487 $sk = (string) $stored_sk;
488 $sealed = Secret::seal( 'site-easy-login-signing-key', $sk );
489 if ( ! $sealed ) {
490 return false;
491 }
492 Conf::update( '_sk', $sealed );
493 }
494 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- benign base64 decoding of the stored ed25519 secret key.
495 $secret_key = base64_decode( $sk, true );
496 if ( false === $secret_key || strlen( $secret_key ) !== SODIUM_CRYPTO_SIGN_SECRETKEYBYTES ) {
497 return false;
498 }
499 $sign = sodium_crypto_sign( (string) $msg, $secret_key );
500 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- benign base64 encoding of the signature.
501 return base64_encode( $sign );
502 }
503
504 /**
505 * Verify a signed msg w/ PK
506 *
507 * @since 4.0
508 * @access public
509 */
510 private function _unpack_b64sign( $msg, $pk ) {
511 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- benign base64 decoding of the signed message and public key.
512 $signed = base64_decode( $msg, true );
513 $public_key = base64_decode( $pk, true );
514 if ( ! $this->_sodium_ready() || false === $signed || false === $public_key || strlen( $public_key ) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES ) {
515 return false;
516 }
517 return sodium_crypto_sign_open( $signed, $public_key );
518 }
519
520 /**
521 * Connect a new child site w/ token
522 *
523 * @since 4.0
524 * @access public
525 */
526 public function connect_site() {
527 global $wpdb;
528 $this->cls( 'Data' )->tb_create( 'site' );
529 if ( ! $this->_sodium_ready() ) {
530 return $this->_admin_error( 'dologin_sodium_unavailable' );
531 }
532
533 // phpcs:ignore WordPress.Security.NonceVerification.Missing
534 if ( empty( $_POST['token'] ) ) {
535 return $this->_admin_error( 'dologin_site_token_missing' );
536 }
537
538 defined( 'debug' ) && debug( 'connection to child' );
539
540 // Generate pk/sk pair if not yet.
541 $this->_init_pksk();
542
543 // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- admin-gated action; benign base64 decode of the connection URL token.
544 $token_link = is_string( $_POST['token'] ) ? base64_decode( sanitize_text_field( wp_unslash( $_POST['token'] ) ), true ) : false;
545 // Block SSRF to internal/invalid hosts (defense in depth even though this path is manage_options-gated).
546 if ( ! wp_http_validate_url( $token_link ) ) {
547 return $this->_admin_error( 'dologin_site_token_invalid' );
548 }
549 defined( 'debug' ) && debug( 'Validated child site connection token URL.' );
550 // Post to the child site w/ pk.
551 $pk = Conf::val( '_pk' );
552 $ts = time();
553 $resp = wp_safe_remote_post(
554 $token_link,
555 array(
556 'body' => array(
557 'pk' => $pk,
558 'site_url' => site_url(),
559 'site_title' => get_bloginfo( 'name' ),
560 'sign' => $this->_pack_b64sign( $ts ),
561 ),
562 'timeout' => 15,
563 'redirection' => 2,
564 'limit_response_size' => 65536,
565 'sslverify' => true,
566 )
567 );
568
569 if ( is_wp_error( $resp ) ) {
570 $error_message = $resp->get_error_message();
571 defined( 'debug' ) && debug( 'Child site connection request failed:', $error_message );
572 return $this->_admin_error( 'dologin_site_connection_failed' );
573 }
574
575 $response_code = (int) wp_remote_retrieve_response_code( $resp );
576 if ( $response_code < 200 || $response_code >= 300 ) {
577 defined( 'debug' ) && debug( 'Child site connection returned HTTP status:', $response_code );
578 return $this->_admin_error( 'dologin_site_connection_failed' );
579 }
580
581 $res = json_decode( wp_remote_retrieve_body( $resp ), true );
582 if ( empty( $res['status'] ) || 'ok' !== $res['status'] || empty( $res['child_title'] ) || ! is_scalar( $res['child_title'] ) || empty( $res['child_url'] ) || ! is_scalar( $res['child_url'] ) || ! isset( $res['child_user_id'], $res['child_user_name'] ) || ! is_scalar( $res['child_user_id'] ) || ! is_scalar( $res['child_user_name'] ) ) {
583 defined( 'debug' ) && debug( 'Child site connection response failed schema validation.' );
584 return $this->_admin_error( 'dologin_site_connection_failed' );
585 }
586 $child_url = esc_url_raw( $res['child_url'] );
587 if ( ! wp_http_validate_url( $child_url ) ) {
588 return $this->_admin_error( 'dologin_site_connection_failed' );
589 }
590
591 $q = "INSERT INTO `$this->_tb` SET title=%s, url=%s, pk=%s, is_child=0, user_id=%d, user_name=%s, dateline=%d, active=1";
592 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery -- $this->_tb is a hardcoded internal table name; values are prepared.
593 $wpdb->query(
594 $wpdb->prepare(
595 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $this->_tb is a hardcoded internal table name; values are prepared.
596 $q,
597 array(
598 sanitize_text_field( $res['child_title'] ),
599 $child_url,
600 '-',
601 (int) $res['child_user_id'],
602 sanitize_text_field( $res['child_user_name'] ),
603 time(),
604 )
605 )
606 );
607 }
608
609 /**
610 * Auth a connection setup from a root site
611 *
612 * @since 4.0
613 */
614 public function connect_auth_init() {
615 global $wpdb;
616
617 $username = 'N/A';
618
619 // This public handshake endpoint must apply the same IP rules and failure limits.
620 if ( $this->cls( 'Auth' )->is_ip_denied() ) {
621 $this->_error_page( 'dologin_ip_denied', 403 );
622 }
623 if ( $this->cls( 'Auth' )->is_rate_limited() ) {
624 $this->_error_page( 'dologin_rate_limited', 429 );
625 }
626 if ( ! $this->_sodium_ready() ) {
627 $this->_error_page( 'dologin_sodium_unavailable', 503 );
628 }
629
630 defined( 'debug' ) && debug( 'Root site connection in' );
631 // Server-to-server handshake authenticated by the signed token, not a nonce.
632 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
633 $raw_token = isset( $_GET[ self::QS_NAME_ROOT_AUTH ] ) && is_string( $_GET[ self::QS_NAME_ROOT_AUTH ] ) ? sanitize_text_field( wp_unslash( $_GET[ self::QS_NAME_ROOT_AUTH ] ) ) : '';
634 $info = explode( '.', $raw_token );
635 if ( 2 !== count( $info ) || empty( $info[0] ) || empty( $info[1] ) ) {
636 return $this->_failed_login( $username );
637 }
638
639 $pid = (int) $info[0];
640 if ( $pid <= 0 ) {
641 return $this->_failed_login( $username );
642 }
643
644 // Verify reord.
645 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery --$this->_tb is a hardcoded internal table name; id is prepared.
646 $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM `$this->_tb` WHERE id = %d", $pid ) );
647 if ( ! $row ) {
648 return $this->_failed_login( $username );
649 }
650 $user_info = get_userdata( $row->user_id );
651 if ( ! $user_info ) {
652 return $this->_failed_login( $username );
653 }
654 $username = $user_info->user_login;
655 if ( ! Secret::verify_token( 'site-connection', (string) $info[1], (string) $row->hash ) ) {
656 return $this->_failed_login( $username );
657 }
658 if ( $row->active != 1 || $row->is_child != 1 || $row->pk ) {
659 defined( 'debug' ) && debug( 'Invalid token record' );
660 $this->_error_page( 'dologin_invalid_token_record', 403 );
661 }
662
663 if ( time() - $row->dateline > 3600 ) {
664 defined( 'debug' ) && debug( 'Token expired' );
665 $this->_error_page( 'dologin_token_expired', 410 );
666 }
667
668 // Verify root site info.
669 // phpcs:ignore WordPress.Security.NonceVerification.Missing
670 $root_url = isset( $_POST['site_url'] ) && is_string( $_POST['site_url'] ) ? esc_url_raw( wp_unslash( $_POST['site_url'] ) ) : '';
671 // phpcs:ignore WordPress.Security.NonceVerification.Missing
672 $root_title = isset( $_POST['site_title'] ) && is_string( $_POST['site_title'] ) ? sanitize_text_field( wp_unslash( $_POST['site_title'] ) ) : '';
673 // phpcs:ignore WordPress.Security.NonceVerification.Missing
674 $root_pk = isset( $_POST['pk'] ) && is_string( $_POST['pk'] ) ? sanitize_text_field( wp_unslash( $_POST['pk'] ) ) : '';
675 // phpcs:ignore WordPress.Security.NonceVerification.Missing
676 $sign = isset( $_POST['sign'] ) && is_string( $_POST['sign'] ) ? sanitize_text_field( wp_unslash( $_POST['sign'] ) ) : '';
677 $root_scheme = wp_parse_url( $root_url, PHP_URL_SCHEME );
678 if ( ! $root_url || ! in_array( $root_scheme, array( 'http', 'https' ), true ) || ! $root_title || ! $root_pk || ! $sign ) {
679 defined( 'debug' ) && debug( 'Invalid dologin connect root data' );
680 $this->_error_page( 'dologin_site_token_invalid', 403 );
681 }
682 $signed_ts = $this->_unpack_b64sign( $sign, $root_pk );
683 if ( ! is_string( $signed_ts ) || ! preg_match( '/^[0-9]+$/D', $signed_ts ) || (int) $signed_ts < time() - 3600 || (int) $signed_ts > time() + 300 ) { // Root and child site clocks cannot be materially out of sync.
684 defined( 'debug' ) && debug( 'dologin connect root clock should not diff w/ child more than 1 hour' );
685 $this->_error_page( 'dologin_site_clock_mismatch', 403 );
686 }
687 if ( $root_url == site_url() ) {
688 defined( 'debug' ) && debug( 'dologin connect root site url same as child' );
689 $this->_error_page( 'dologin_site_same_url', 409 );
690 }
691 // Only one record allowed per root site pk per user_id.
692 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery --$this->_tb is a hardcoded internal table name; values are prepared.
693 $exists = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `$this->_tb` WHERE pk = %s AND user_id = %d", $root_pk, $row->user_id ) );
694 if ( $exists > 0 ) {
695 defined( 'debug' ) && debug( 'dologin connect root site pk already exists for user' );
696 $this->_error_page( 'dologin_site_already_connected', 409 );
697 }
698
699 // Can login, update record first.
700 $q = "UPDATE `$this->_tb` SET title=%s,url=%s,pk=%s WHERE id = %d AND pk = '' AND active = 1";
701 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery --$this->_tb is a hardcoded internal table name; values are prepared.
702 $updated = $wpdb->query( $wpdb->prepare( $q, array( $root_title, $root_url, $root_pk, $pid ) ) );
703 if ( 1 !== $updated ) {
704 $this->_error_page( 'dologin_token_used', 410 );
705 }
706
707 nocache_headers();
708
709 exit(
710 wp_json_encode(
711 array(
712 'status' => 'ok',
713 'child_title' => get_bloginfo( 'name' ),
714 'child_url' => admin_url(),
715 'child_user_id' => $row->user_id,
716 'child_user_name' => $username,
717 )
718 )
719 );
720 }
721
722 /**
723 * Handler
724 *
725 * @since 1.4
726 */
727 public function handler() {
728 $type = Router::verify_type();
729
730 switch ( $type ) {
731 case self::TYPE_GEN_TOKEN:
732 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
733 $this->gen_token( isset( $_GET['uid'] ) ? (int) $_GET['uid'] : 0 );
734 break;
735
736 case self::TYPE_CONNECT:
737 $this->connect_site();
738 break;
739
740 case self::TYPE_EASY_LOGIN:
741 $this->_easy_login();
742 break;
743
744 case self::TYPE_LOCK:
745 $this->_lock_link();
746 break;
747
748 case self::TYPE_DEL:
749 $this->del_link();
750 break;
751
752 default:
753 break;
754 }
755 }
756 }
757