PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 All 507 releases
← All changes | jetpack_vendor/automattic/jetpack-connection/src/class-manager.php +864 -15 16.2 → 16.3-beta View file →
@@ -39,8 +39,23 @@
39 39 */
40 40 const SITE_DATA_TRANSIENT_PREFIX = 'jetpack_site_data_';
41 41
42 42 /**
43 + * Why `has_protected_owner()` answered false, and what would change it.
44 + *
45 + * `RE_EVALUATE` is the race: the gate said false, and by the time the state was classified the
46 + * owner matched after all. It is not a problem to report, it is an instruction to ask again.
47 + *
48 + * @since 9.4.0
49 + */
50 + const PO_STATE_NOT_ELIGIBLE = 'NOT_ELIGIBLE';
51 + const PO_STATE_NEEDS_CONNECT_TO_ESTABLISH = 'NEEDS_CONNECT_TO_ESTABLISH';
52 + const PO_STATE_CAN_ESTABLISH = 'CAN_ESTABLISH';
53 + const PO_STATE_NEEDS_OWNER_RECONNECT = 'NEEDS_OWNER_RECONNECT';
54 + const PO_STATE_NEEDS_DIFFERENT_OWNER = 'NEEDS_DIFFERENT_OWNER';
55 + const PO_STATE_RE_EVALUATE = 'RE_EVALUATE';
56 +
57 + /**
43 58 * A copy of the raw POST data for signature verification purposes.
44 59 *
45 60 * @var string
46 61 */
@@ -171,8 +186,11 @@
171 186 add_action( 'jetpack_verify_signature_error', array( $manager, 'track_xmlrpc_error' ) );
172 187
173 188 Webhooks::init( $manager );
174 189
190 + add_action( 'pre_update_jetpack_option_user_tokens', array( $manager, 'unbind_wpcom_user_ids_for_new_tokens' ), 10, 2 );
191 + add_action( 'jetpack_user_authorized', array( $manager, 'reconcile_protected_owner' ) );
192 +
175 193 // Unlink user before deleting the user from WP.com.
176 194 add_action( 'deleted_user', array( $manager, 'disconnect_user_force' ), 9, 1 );
177 195 add_action( 'remove_user_from_blog', array( $manager, 'disconnect_user_force' ), 9, 1 );
178 196
@@ -1005,8 +1023,81 @@
1005 1023 return $user_data;
1006 1024 }
1007 1025
1008 1026 /**
1027 + * Returns the WordPress.com user ID of a connected user.
1028 + *
1029 + * Answers only for a user who currently holds a token: the binding outlives any one token, so
1030 + * connectedness is checked here rather than inferred from a row existing. Resolving an unbound
1031 + * user costs a blocking request to WordPress.com, so this is not safe to call per row.
1032 + *
1033 + * @since 9.2.0
1034 + *
1035 + * @param int|false $user_id The local user identifier. Default is the current user.
1036 + * @return int The WordPress.com user ID, or 0 if it could not be determined.
1037 + */
1038 + public function resolve_wpcom_user_id( $user_id = false ) {
1039 + $user_id = $user_id ? absint( $user_id ) : get_current_user_id();
1040 +
1041 + // The binding outlives the token, unlike the transient behind `get_connected_user_data()`,
1042 + // so connectedness is checked here rather than left to the lookup below.
1043 + if ( ! $user_id || ! $this->is_user_connected( $user_id ) ) {
1044 + return 0;
1045 + }
1046 +
1047 + $bound = Utils::get_wpcom_user_id( $user_id );
1048 +
1049 + if ( $bound ) {
1050 + return $bound;
1051 + }
1052 +
1053 + $user_data = $this->get_connected_user_data( $user_id );
1054 +
1055 + // Callers must read 0 as "unknown", never as "no match": a failed lookup lands here too.
1056 + if ( empty( $user_data['ID'] ) ) {
1057 + return 0;
1058 + }
1059 +
1060 + Utils::set_wpcom_user_id( $user_id, (int) $user_data['ID'] );
1061 +
1062 + return (int) $user_data['ID'];
1063 + }
1064 +
1065 + /**
1066 + * Unbind the WordPress.com user ID of any user whose token is new.
1067 + *
1068 + * Every path that changes a user's token writes the `user_tokens` option, so this covers
1069 + * authorize, remote connect and the REST endpoint alike. A token that is added or replaced can
1070 + * name a different WordPress.com account, so any binding it would answer with is unverified. A
1071 + * token merely removed leaves the binding correct, and other subsystems store their own meaning
1072 + * in the same meta, so removals are left alone.
1073 + *
1074 + * @internal Hooked on `pre_update_jetpack_option_user_tokens`, which fires before the write.
1075 + * @since 9.2.0
1076 + *
1077 + * @param string $name The option name.
1078 + * @param mixed $value The tokens about to be written.
1079 + */
1080 + public function unbind_wpcom_user_ids_for_new_tokens( $name, $value ) {
1081 + if ( ! is_array( $value ) ) {
1082 + return;
1083 + }
1084 +
1085 + // A site disconnect deletes the option outright, so the first write back has nothing to
1086 + // diff against — treat that as every token being new rather than skipping the check.
1087 + $previous = \Jetpack_Options::get_option( 'user_tokens' );
1088 + $previous = is_array( $previous ) ? $previous : array();
1089 +
1090 + // Iterating the incoming tokens covers a token being added as well as replaced, and skips
1091 + // removal for free: a user absent from the new set is never visited.
1092 + foreach ( $value as $user_id => $token ) {
1093 + if ( ( $previous[ $user_id ] ?? null ) !== $token ) {
1094 + Utils::delete_wpcom_user_id( $user_id );
1095 + }
1096 + }
1097 + }
1098 +
1099 + /**
1009 1100 * Drop the cached WordPress.com site record.
1010 1101 *
1011 1102 * A caller that fetched the record by another route holds something newer than the cache can,
1012 1103 * and `jetpack_site_data_fetched` fires on a cached read too. The cached copy has to go, or it
@@ -1223,18 +1314,26 @@
1223 1314
1224 1315 /**
1225 1316 * Determines whether the connection ownership can be transferred to another user.
1226 1317 *
1227 - * The default Jetpack connection uses a transferable ownership model. A consumer
1228 - * can declare ownership locked by returning `false` from the `jetpack_connection_ownership_transferable`
1229 - * filter. This is the single chokepoint used both when deciding which connection-error
1230 - * CTA to surface and (eventually) when performing an ownership change.
1318 + * The default Jetpack connection uses a transferable ownership model. A set protected owner
1319 + * anchor locks it outright; otherwise a consumer can declare ownership locked by returning
1320 + * `false` from the `jetpack_connection_ownership_transferable` filter. This is the single
1321 + * chokepoint used both when deciding which connection-error CTA to surface and (eventually)
1322 + * when performing an ownership change.
1231 1323 *
1232 1324 * @since 8.8.0
1325 + * @since 9.3.0 A locked protected owner anchor makes ownership non-transferable.
1233 1326 *
1234 1327 * @return bool True if ownership can be transferred, false if it is locked.
1235 1328 */
1236 1329 public function is_ownership_transferable() {
1330 + // Keyed on the anchor, never on has_protected_owner(): an owner who does not match the
1331 + // anchor is exactly when ownership must stay locked.
1332 + if ( Protected_Owner::is_locked() ) {
1333 + return false;
1334 + }
1335 +
1237 1336 /**
1238 1337 * Filters whether the Jetpack connection ownership can be transferred.
1239 1338 *
1240 1339 * Return `false` to lock ownership so it can never be taken over.
@@ -1246,8 +1345,592 @@
1246 1345 return (bool) apply_filters( 'jetpack_connection_ownership_transferable', true );
1247 1346 }
1248 1347
1249 1348 /**
1349 + * Whether a protected owner is required right now.
1350 + *
1351 + * Evaluated at the moment of the request, not as a standing declaration: a consumer may
1352 + * legitimately answer false while it is installed and active — running in test mode, say —
1353 + * and true only at the lifecycle moment that binds something to the owner's identity.
1354 + *
1355 + * @since 9.3.0
1356 + *
1357 + * @return bool True if a protected owner is required at this moment. Default false.
1358 + */
1359 + public function requires_protected_owner() {
1360 + /**
1361 + * Filters whether a protected owner is required at this moment.
1362 + *
1363 + * Return `true` at the point a feature is about to bind to the connection owner's
1364 + * identity. Answering false at other times is expected and supported.
1365 + *
1366 + * @since 9.3.0
1367 + *
1368 + * @param bool $required Whether a protected owner is required. Default false.
1369 + */
1370 + return (bool) apply_filters( 'jetpack_connection_requires_protected_owner', false );
1371 + }
1372 +
1373 + /**
1374 + * Whether the connection owner is the protected owner the anchor names.
1375 + *
1376 + * This is the question consumers gate on before binding anything to the owner's identity.
1377 + *
1378 + * Reads the binding of the current owner rather than searching for whoever holds the anchored
1379 + * ID, so a row on any other user cannot affect the answer. Requiring the owner to hold a live
1380 + * token on top of that is what keeps a row written by another subsystem from ever satisfying
1381 + * this: both halves are load-bearing, and there are tests for each.
1382 + *
1383 + * @since 9.3.0
1384 + *
1385 + * @return bool
1386 + */
1387 + public function has_protected_owner() {
1388 + $anchor = Protected_Owner::get_locked();
1389 +
1390 + if ( ! $anchor ) {
1391 + return false;
1392 + }
1393 +
1394 + $owner_id = $this->get_connection_owner_id();
1395 +
1396 + if ( ! $owner_id ) {
1397 + return false;
1398 + }
1399 +
1400 + return $this->resolve_wpcom_user_id( $owner_id ) === (int) $anchor['wpcom_user_id'];
1401 + }
1402 +
1403 + /**
1404 + * Classify why `has_protected_owner()` answered false, and what would change it.
1405 + *
1406 + * Deliberately inspects only what the gate inspects — the anchor and the current connection
1407 + * owner — so the two can never disagree about the same site. Anything needing a user search or
1408 + * a reachability probe is a different question and is not answered here.
1409 + *
1410 + * `is_current_user_the_po` reads the current user's own stored binding, never a search for
1411 + * whoever holds the anchored ID, so it cannot be confused by a second user carrying the same
1412 + * meta, and never costs a network call. It is a hint for copy, not a gate.
1413 + *
1414 + * @since 9.4.0
1415 + *
1416 + * @return array{status: string, is_current_user_the_po: bool}
1417 + */
1418 + public function resolve_protected_owner_state() {
1419 + $anchor = Protected_Owner::get_locked();
1420 + $current_id = get_current_user_id();
1421 +
1422 + // Only meaningful against an anchor: with none, there is nothing for the user to be.
1423 + // Reads the stored binding rather than resolving it, so classifying a state never costs a
1424 + // WordPress.com round trip. An unbound user reads as false and gets the generic copy.
1425 + $is_current_user_the_po = $anchor
1426 + && Utils::get_wpcom_user_id( $current_id ) === (int) $anchor['wpcom_user_id'];
1427 +
1428 + if ( ! $anchor ) {
1429 + $roles = new Roles();
1430 +
1431 + if ( ! current_user_can( 'jetpack_connect' ) || ! current_user_can( $roles->translate_role_to_cap( 'administrator' ) ) ) {
1432 + // Eligibility is being an admin, not holding the master slot.
1433 + $status = self::PO_STATE_NOT_ELIGIBLE;
1434 + } elseif ( ! $this->is_user_connected( $current_id ) ) {
1435 + // A WordPress.com identity has to exist before it can be confirmed and locked.
1436 + $status = self::PO_STATE_NEEDS_CONNECT_TO_ESTABLISH;
1437 + } else {
1438 + $status = self::PO_STATE_CAN_ESTABLISH;
1439 + }
1440 + } else {
1441 + $owner_id = $this->get_connection_owner_id();
1442 + $owner_wpcom_id = $owner_id ? $this->resolve_wpcom_user_id( $owner_id ) : 0;
1443 +
1444 + if ( ! $owner_wpcom_id ) {
1445 + // A zero is "could not determine", never "does not match", so an owner whose
1446 + // identity cannot be confirmed is reported as needing to reconnect, not replaced.
1447 + $status = self::PO_STATE_NEEDS_OWNER_RECONNECT;
1448 + } elseif ( $owner_wpcom_id !== (int) $anchor['wpcom_user_id'] ) {
1449 + // Legitimate, not broken: the first admin to connect takes a vacant master slot,
1450 + // so an agency can hold it while the protected owner is away.
1451 + $status = self::PO_STATE_NEEDS_DIFFERENT_OWNER;
1452 + } else {
1453 + $status = self::PO_STATE_RE_EVALUATE;
1454 + }
1455 + }
1456 +
1457 + return array(
1458 + 'status' => $status,
1459 + 'is_current_user_the_po' => $is_current_user_the_po,
1460 + );
1461 + }
1462 +
1463 + /**
1464 + * Reconcile this site's protected owner against WordPress.com, which is the owner of record.
1465 + *
1466 + * Runs at connect, when the site has a fresh user token and an answer is cheap, and only once
1467 + * something is anchored: a site with no protected owner asks nothing and behaves as it did
1468 + * before this existed. One answer settles the rest — whether an owner still exists, whether
1469 + * the anchor names them, and whether the user connecting is them — so the anchor, the binding
1470 + * and the master slot are decided together rather than from two calls that could disagree.
1471 + *
1472 + * Only an answer moves anything. Unreachable, refused, unimplemented and malformed leave the
1473 + * anchor exactly as it was: it was confirmed once, and a request that never arrived is no
1474 + * evidence against it.
1475 + *
1476 + * @internal Hooked on `jetpack_user_authorized`.
1477 + * @since 9.8.0
1478 + *
1479 + * @return bool Whether WordPress.com confirmed the anchored identity.
1480 + */
1481 + public function reconcile_protected_owner() {
1482 + $user_id = get_current_user_id();
1483 +
1484 + if ( ! $user_id ) {
1485 + return false;
1486 + }
1487 +
1488 + $anchor = Protected_Owner::get();
1489 +
1490 + // Nothing anchored is nothing to reconcile, and a site with no protected owner must behave
1491 + // exactly as it did before this existed — including making no request. Such a site reaches
1492 + // an owner through the claim instead, which is where confirming belongs.
1493 + if ( ! $anchor ) {
1494 + return false;
1495 + }
1496 +
1497 + $record = $this->query_protected_owner_record( (int) $anchor['wpcom_user_id'] );
1498 +
1499 + // Silence is not an answer. Unreachable, refused and unimplemented leave the anchor exactly
1500 + // as it was: it was confirmed once, and a request that never arrived is no evidence against
1501 + // it. Dropping a good lock because WordPress.com had a bad minute costs a merchant their
1502 + // payouts until the owner happens to connect again.
1503 + if ( ! is_array( $record ) || ! isset( $record['has_owner'] ) ) {
1504 + return false;
1505 + }
1506 +
1507 + // WordPress.com no longer has an owner of record, so neither does this site. Support
1508 + // clearing it at that end is how a wrongly anchored site recovers.
1509 + if ( ! $record['has_owner'] ) {
1510 + Protected_Owner::clear();
1511 +
1512 + return false;
1513 + }
1514 +
1515 + $caller_wpcom_user_id = (int) ( $record['caller_wpcom_user_id'] ?? 0 );
1516 +
1517 + // The identity is disclosed only to the owner it names, so this is the one branch that can
1518 + // learn it — and what it settles is which account the anchor should name, since
1519 + // WordPress.com may have moved the owner since this site last asked.
1520 + if ( ! empty( $record['is_caller'] ) ) {
1521 + return $this->adopt_protected_owner( $user_id, $caller_wpcom_user_id, $anchor );
1522 + }
1523 +
1524 + // Connecting cleared this, and it is the caller's own identity rather than the owner's, so
1525 + // it is written whoever they are. Nothing is anchored on this path, so an early write
1526 + // cannot strand a half-finished lock.
1527 + if ( $caller_wpcom_user_id ) {
1528 + Utils::set_wpcom_user_id( $user_id, $caller_wpcom_user_id );
1529 + }
1530 +
1531 + // Somebody else is connecting. WordPress.com confirms the anchored identity rather than
1532 + // naming the owner, so the answer is the same whoever asks.
1533 + if ( empty( $record['matches'] ) ) {
1534 + Protected_Owner::clear();
1535 +
1536 + return false;
1537 + }
1538 +
1539 + return true;
1540 + }
1541 +
1542 + /**
1543 + * Take WordPress.com's word that the connecting user owns this site.
1544 + *
1545 + * @since 9.8.0
1546 + *
1547 + * @param int $user_id The connecting local user.
1548 + * @param int $wpcom_user_id The connecting user's WordPress.com identity, which this
1549 + * branch has just been told is the owner of record.
1550 + * @param array|null $anchor What this site has anchored, if anything.
1551 + * @return bool Whether the anchor now names that identity.
1552 + */
1553 + private function adopt_protected_owner( $user_id, $wpcom_user_id, $anchor ) {
1554 + // An owner without an identity is a malformed answer, and trusting it would lock the site
1555 + // to nobody.
1556 + if ( ! $wpcom_user_id ) {
1557 + return false;
1558 + }
1559 +
1560 + // Anchored before the binding, so a failed write leaves nothing behind for a later
1561 + // connection to build on. Re-pointing only moves the cached local ID, so it is right only
1562 + // while the anchored identity is the one WordPress.com just confirmed.
1563 + if ( $anchor && (int) $anchor['wpcom_user_id'] === $wpcom_user_id ) {
1564 + Protected_Owner::repoint( $user_id );
1565 + } elseif ( ! Protected_Owner::set( $wpcom_user_id, $user_id ) ) {
1566 + return false;
1567 + }
1568 +
1569 + // Connecting clears the binding, so this writes back the one the answer just confirmed.
1570 + Utils::set_wpcom_user_id( $user_id, $wpcom_user_id );
1571 +
1572 + // Eligibility for the master slot is being an administrator here, which the owner of record
1573 + // need not be.
1574 + if ( user_can( $user_id, ( new Roles() )->translate_role_to_cap( 'administrator' ) )
1575 + && (int) \Jetpack_Options::get_option( 'master_user' ) !== $user_id ) {
1576 + \Jetpack_Options::update_option( 'master_user', $user_id );
1577 + }
1578 +
1579 + return true;
1580 + }
1581 +
1582 + /**
1583 + * Ask WordPress.com whether it still holds the anchored identity as this site's owner.
1584 + *
1585 + * Split from `reconcile_protected_owner()` so the decision it drives can be exercised without a
1586 + * network, which is the half worth testing: every branch of it changes whether a site gates a
1587 + * live feature. The anchored ID is sent so the answer confirms rather than discloses.
1588 + *
1589 + * @since 9.8.0
1590 + *
1591 + * @param int $anchored_wpcom_user_id The WordPress.com identity this site has anchored.
1592 + * @return array|null The record, or null when WordPress.com could not answer.
1593 + */
1594 + protected function query_protected_owner_record( $anchored_wpcom_user_id ) {
1595 + return $this->request_protected_owner_record(
1596 + '/reconcile',
1597 + array( 'anchored_wpcom_user_id' => (int) $anchored_wpcom_user_id )
1598 + );
1599 + }
1600 +
1601 + /**
1602 + * Claim this site's protected ownership for the current user with WordPress.com.
1603 + *
1604 + * Split from `set_protected_owner()` so the decision it drives can be exercised without a
1605 + * network. The identity travels in the signature rather than the payload, so nothing is sent.
1606 + *
1607 + * @since 9.8.0
1608 + *
1609 + * @return array|null The record, or null when WordPress.com could not answer.
1610 + */
1611 + protected function assert_protected_owner_record() {
1612 + return $this->request_protected_owner_record();
1613 + }
1614 +
1615 + /**
1616 + * Give up this site's protected ownership with WordPress.com.
1617 + *
1618 + * Split from `release_protected_owner()` so the decision it drives can be exercised without a
1619 + * network. The identity travels in the signature rather than the payload, so WordPress.com
1620 + * decides whether the caller is the owner it holds.
1621 + *
1622 + * @since 9.9.0
1623 + *
1624 + * @return array|null The record, or null when WordPress.com could not answer.
1625 + */
1626 + protected function relinquish_protected_owner_record() {
1627 + return $this->request_protected_owner_record( '/release' );
1628 + }
1629 +
1630 + /**
1631 + * Call this site's protected-owner resource on WordPress.com, signed as the current user.
1632 + *
1633 + * @since 9.8.1
1634 + *
1635 + * @param string $route The route below the resource, empty for the resource itself.
1636 + * @param array|null $body The request body, or null to send none.
1637 + * @return array|null The record, or null when WordPress.com could not answer.
1638 + */
1639 + private function request_protected_owner_record( $route = '', $body = null ) {
1640 + $path = sprintf(
1641 + '/sites/%d/jetpack-protected-owner%s',
1642 + (int) \Jetpack_Options::get_option( 'id' ),
1643 + $route
1644 + );
1645 +
1646 + $response = Client::wpcom_json_api_request_as_user( $path, '2', array( 'method' => 'POST' ), $body );
1647 +
1648 + // Anything but a 200 is silence rather than an answer: unreachable, refused, or a
1649 + // WordPress.com that does not implement the route. Every caller fails closed on null.
1650 + if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
1651 + return null;
1652 + }
1653 +
1654 + $record = json_decode( wp_remote_retrieve_body( $response ), true );
1655 +
1656 + return is_array( $record ) ? $record : null;
1657 + }
1658 +
1659 + /**
1660 + * Record a user as the protected owner and promote them to connection owner.
1661 + *
1662 + * Gated on `jetpack_connect` rather than on a role: a host can narrow that capability and
1663 + * multisite does. It is false while the package is unconfigured, so a caller that has not
1664 + * registered the connection's capabilities is refused rather than trusted.
1665 + *
1666 + * @since 9.3.0
1667 + * @since 9.6.0 No longer takes how the owner was confirmed.
1668 + * @since 9.8.0 WordPress.com records the owner before anything is anchored here.
1669 + *
1670 + * @param int $user_id The local user to anchor.
1671 + * @return true|WP_Error True on success, WP_Error otherwise.
1672 + */
1673 + public function set_protected_owner( $user_id ) {
1674 + // Authorization precedes validation, so an unauthorized caller cannot use the argument
1675 + // errors below to learn which users are administrators or hold a token.
1676 + if ( ! current_user_can( 'jetpack_connect' ) ) {
1677 + return new WP_Error(
1678 + 'protected_owner_forbidden',
1679 + __( 'You do not have permission to manage the protected owner.', 'jetpack-connection' ),
1680 + array( 'status' => 403 )
1681 + );
1682 + }
1683 +
1684 + $user_id = absint( $user_id );
1685 + $roles = new Roles();
1686 +
1687 + if ( ! user_can( $user_id, $roles->translate_role_to_cap( 'administrator' ) ) ) {
1688 + return new WP_Error(
1689 + 'protected_owner_not_admin',
1690 + __( 'The protected owner must be an administrator.', 'jetpack-connection' ),
1691 + array( 'status' => 400 )
1692 + );
1693 + }
1694 +
1695 + // The claim is signed as the current user, so it can only ever anchor the current user.
1696 + // Anchoring somebody else would be an owner assignment they never agreed to.
1697 + if ( $user_id !== get_current_user_id() ) {
1698 + return new WP_Error(
1699 + 'protected_owner_not_self',
1700 + __( 'A protected owner can only be recorded by the user confirming it.', 'jetpack-connection' ),
1701 + array( 'status' => 400 )
1702 + );
1703 + }
1704 +
1705 + // A stored binding that already disagrees with the anchor is enough to refuse. WordPress.com
1706 + // is still asked when this user has no binding, because that answer is what names them.
1707 + if ( $this->local_anchor_names_someone_else( (int) Utils::get_wpcom_user_id( $user_id ) ) ) {
1708 + return $this->protected_owner_claimed_by_other();
1709 + }
1710 +
1711 + // WordPress.com is asked before anything is written here. It owns the record, so a claim it
1712 + // has not accepted must not leave a locked anchor behind on this site.
1713 + $record = $this->assert_protected_owner_record();
1714 +
1715 + // Fail closed: unreachable, refused, or a WordPress.com that does not implement the call.
1716 + // A site that cannot get an answer must not end up protecting anybody on its own say-so.
1717 + if ( ! is_array( $record ) || empty( $record['status'] ) ) {
1718 + return new WP_Error(
1719 + 'protected_owner_unconfirmed',
1720 + __( 'Could not reach WordPress.com to confirm the protected owner.', 'jetpack-connection' ),
1721 + array( 'status' => 503 )
1722 + );
1723 + }
1724 +
1725 + // Somebody else already holds this site. Beyond support there is no way past this, which is
1726 + // the point: an owner that could be overwritten by the next claimant protects nobody.
1727 + if ( 'locked_to_other' === $record['status'] ) {
1728 + return $this->protected_owner_claimed_by_other();
1729 + }
1730 +
1731 + // Only an accepted claim is anchored: any other verdict is refused, even one carrying an ID.
1732 + if ( ! in_array( $record['status'], array( 'recorded', 'already_yours' ), true ) || empty( $record['wpcom_user_id'] ) ) {
1733 + return new WP_Error(
1734 + 'protected_owner_not_verified',
1735 + __( 'Could not confirm the protected owner with WordPress.com.', 'jetpack-connection' ),
1736 + array( 'status' => 400 )
1737 + );
1738 + }
1739 +
1740 + // A `recorded` answer must not replace an anchor that already names a different account.
1741 + if ( $this->local_anchor_names_someone_else( (int) $record['wpcom_user_id'] ) ) {
1742 + return $this->protected_owner_claimed_by_other();
1743 + }
1744 +
1745 + // Store the binding the anchor will be compared against, so the gate reads local state from
1746 + // here on. Routed through the deduping writer, which clears the ID off any previous holder.
1747 + Utils::set_wpcom_user_id( $user_id, (int) $record['wpcom_user_id'] );
1748 +
1749 + if ( ! Protected_Owner::set( (int) $record['wpcom_user_id'], $user_id ) ) {
1750 + return new WP_Error(
1751 + 'protected_owner_not_stored',
1752 + __( 'Could not store the protected owner.', 'jetpack-connection' ),
1753 + array( 'status' => 500 )
1754 + );
1755 + }
1756 +
1757 + // Written directly rather than through update_connection_owner(): that round-trips to
1758 + // WordPress.com first, and its ownership-change guard will refuse the anchor just set here.
1759 + \Jetpack_Options::update_option( 'master_user', $user_id );
1760 +
1761 + return true;
1762 + }
1763 +
1764 + /**
1765 + * Release the protected owner, leaving ownership open to any connected administrator.
1766 + *
1767 + * WordPress.com holds the record, so it is cleared there first. An anchor dropped only here
1768 + * would leave WordPress.com refusing every later claim as `locked_to_other`, locking the site
1769 + * to nobody rather than unlocking it.
1770 + *
1771 + * Leaves `master_user` alone: releasing the lock does not change who the owner is.
1772 + *
1773 + * @since 9.9.0
1774 + *
1775 + * @return true|WP_Error True on success, WP_Error otherwise.
1776 + */
1777 + public function release_protected_owner() {
1778 + // Authorization precedes everything else, so an unauthorized caller cannot use the
1779 + // refusals below to learn whether this site is protected or by whom.
1780 + if ( ! current_user_can( 'jetpack_connect' ) ) {
1781 + return new WP_Error(
1782 + 'protected_owner_forbidden',
1783 + __( 'You do not have permission to manage the protected owner.', 'jetpack-connection' ),
1784 + array( 'status' => 403 )
1785 + );
1786 + }
1787 +
1788 + // Nothing anchored is already released, so repeating the call is not an error. It can also
1789 + // be an anchor lost while WordPress.com kept its record, which this site cannot tell apart
1790 + // and cannot recover from alone — hence a warning rather than silence.
1791 + $anchor = Protected_Owner::get_locked();
1792 +
1793 + if ( ! $anchor ) {
1794 + wp_trigger_error(
1795 + __METHOD__,
1796 + 'Released with no protected owner on record. If WordPress.com still holds one, this site can no longer claim it back.',
1797 + E_USER_WARNING
1798 + );
1799 +
1800 + return true;
1801 + }
1802 +
1803 + // A local hint that spares an obvious refusal a round trip. WordPress.com is asked anyway
1804 + // whenever this passes, and its answer is the one that decides.
1805 + if ( Utils::get_wpcom_user_id( get_current_user_id() ) !== (int) $anchor['wpcom_user_id'] ) {
1806 + return $this->protected_owner_release_refused();
1807 + }
1808 +
1809 + $record = $this->relinquish_protected_owner_record();
1810 +
1811 + // Fail closed: unreachable, refused, or a WordPress.com that does not implement the call.
1812 + // Clearing on silence would unlock a site WordPress.com still holds.
1813 + if ( ! is_array( $record ) || empty( $record['status'] ) ) {
1814 + return new WP_Error(
1815 + 'protected_owner_unreleased',
1816 + __( 'Could not reach WordPress.com to release the protected owner.', 'jetpack-connection' ),
1817 + array( 'status' => 503 )
1818 + );
1819 + }
1820 +
1821 + if ( 'not_owner' === $record['status'] ) {
1822 + return $this->protected_owner_release_refused();
1823 + }
1824 +
1825 + // `no_owner` is WordPress.com reporting it holds nothing to release, which is the state
1826 + // this call asks for, so the stale anchor here clears alongside an accepted release.
1827 + if ( ! in_array( $record['status'], array( 'released', 'no_owner' ), true ) ) {
1828 + return new WP_Error(
1829 + 'protected_owner_not_released',
1830 + __( 'Could not release the protected owner with WordPress.com.', 'jetpack-connection' ),
1831 + array( 'status' => 400 )
1832 + );
1833 + }
1834 +
1835 + $cleared = $this->clear_protected_owner();
1836 +
1837 + // WordPress.com has already let go, so a local delete that failed is unfinished cleanup
1838 + // rather than a release that did not happen. Retrying is what fixes it: reconcile only
1839 + // runs when somebody authorizes, and WordPress.com now answers this call with `no_owner`.
1840 + if ( is_wp_error( $cleared ) && 'protected_owner_not_cleared' === $cleared->get_error_code() ) {
1841 + return new WP_Error(
1842 + 'protected_owner_not_cleared',
1843 + __( 'Ownership was released with WordPress.com, but this site could not finish clearing it. Try again.', 'jetpack-connection' ),
1844 + array( 'status' => 500 )
1845 + );
1846 + }
1847 +
1848 + return $cleared;
1849 + }
1850 +
1851 + /**
1852 + * The refusal for a caller who is not the owner WordPress.com holds.
1853 + *
1854 + * @since 9.9.0
1855 + *
1856 + * @return WP_Error
1857 + */
1858 + private function protected_owner_release_refused() {
1859 + return new WP_Error(
1860 + 'protected_owner_not_owner',
1861 + __( 'Only the confirmed owner can release ownership of this site.', 'jetpack-connection' ),
1862 + array( 'status' => 403 )
1863 + );
1864 + }
1865 +
1866 + /**
1867 + * Whether a WordPress.com user id would replace the stored anchor.
1868 + *
1869 + * Zero means this user is not named yet, so it is not a conflict.
1870 + *
1871 + * @since 9.9.0
1872 + *
1873 + * @param int $wpcom_user_id WordPress.com user the claim would anchor.
1874 + * @return bool
1875 + */
1876 + private function local_anchor_names_someone_else( $wpcom_user_id ) {
1877 + $anchor = Protected_Owner::get_locked();
1878 +
1879 + return $anchor && $wpcom_user_id && (int) $anchor['wpcom_user_id'] !== (int) $wpcom_user_id;
1880 + }
1881 +
1882 + /**
1883 + * The support path for a site a different account already protects.
1884 + *
1885 + * @since 9.9.0
1886 + *
1887 + * @return WP_Error
1888 + */
1889 + private function protected_owner_claimed_by_other() {
1890 + return new WP_Error(
1891 + 'protected_owner_claimed_by_other',
1892 + __( 'This site is already protected by a different WordPress.com account. Contact support.', 'jetpack-connection' ),
1893 + array( 'status' => 409 )
1894 + );
1895 + }
1896 +
1897 + /**
1898 + * Drop the protected owner anchor, unlocking ownership.
1899 + *
1900 + * Gated on `jetpack_connect` like establishing one, releasing a lock being the more
1901 + * consequential half. The `@internal` tag is documentation; the capability is enforcement.
1902 + *
1903 + * @internal Recovery and support flows only. Consumers must not call this.
1904 + * @since 9.3.0
1905 + *
1906 + * @return true|WP_Error True once no anchor is set, WP_Error otherwise.
1907 + */
1908 + public function clear_protected_owner() {
1909 + if ( ! current_user_can( 'jetpack_connect' ) ) {
1910 + return new WP_Error(
1911 + 'protected_owner_forbidden',
1912 + __( 'You do not have permission to manage the protected owner.', 'jetpack-connection' ),
1913 + array( 'status' => 403 )
1914 + );
1915 + }
1916 +
1917 + Protected_Owner::clear();
1918 +
1919 + // Asked of the outcome rather than of `delete_option()`, which also reports false for an
1920 + // anchor that was already absent — the state the caller asked for.
1921 + if ( Protected_Owner::get() ) {
1922 + return new WP_Error(
1923 + 'protected_owner_not_cleared',
1924 + __( 'Could not clear the protected owner.', 'jetpack-connection' ),
1925 + array( 'status' => 500 )
1926 + );
1927 + }
1928 +
1929 + return true;
1930 + }
1931 +
1932 + /**
1250 1933 * Connects the user with a specified ID to a WordPress.com user using the
1251 1934 * remote login flow.
1252 1935 *
1253 1936 * @access public
@@ -1418,8 +2101,11 @@
1418 2101 /**
1419 2102 * Update the connection owner.
1420 2103 *
1421 2104 * @since 1.29.0
2105 + * @since 9.3.0 Refused while ownership is locked.
2106 + * @since 9.9.0 The anchored owner passes the lock, and moving the site off them
2107 + * releases the anchor.
1422 2108 *
1423 2109 * @param int $new_owner_id The ID of the user to become the connection owner.
1424 2110 *
1425 2111 * @return true|WP_Error True if owner successfully changed, WP_Error otherwise.
@@ -1424,8 +2110,18 @@
1424 2110 *
1425 2111 * @return true|WP_Error True if owner successfully changed, WP_Error otherwise.
1426 2112 */
1427 2113 public function update_connection_owner( $new_owner_id ) {
2114 + // Answered before the arguments are validated: no candidate is valid while ownership is
2115 + // locked, and an argument error would suggest a retry that cannot work.
2116 + if ( ! $this->is_ownership_transferable() && ! $this->current_user_may_move_locked_ownership() ) {
2117 + return new WP_Error(
2118 + 'ownership_locked',
2119 + __( 'The connection owner is locked on this site.', 'jetpack-connection' ),
2120 + array( 'status' => 403 )
2121 + );
2122 + }
2123 +
1428 2124 $roles = new Roles();
1429 2125 if ( ! user_can( $new_owner_id, $roles->translate_role_to_cap( 'administrator' ) ) ) {
1430 2126 return new WP_Error(
1431 2127 'new_owner_not_admin',
@@ -1462,8 +2158,10 @@
1462 2158
1463 2159 // Clear the memoized connection owner ID since it changed
1464 2160 self::$connection_owner_id = null;
1465 2161
2162 + $this->release_anchor_after_transfer( $new_owner_id, $owner_updated_wpcom );
2163 +
1466 2164 // Track it.
1467 2165 ( new Tracking() )->record_user_event( 'set_connection_owner_success' );
1468 2166
1469 2167 return true;
@@ -1475,15 +2173,99 @@
1475 2173 );
1476 2174 }
1477 2175
1478 2176 /**
2177 + * Whether the current user may move the connection despite a locked anchor.
2178 + *
2179 + * The anchor protects an identity, so the owner it names is the one person it is not against.
2180 + *
2181 + * A local hint rather than proof of who that is: the binding is not unique site-wide, and the
2182 + * anchored owner is often not the connection owner here — taking a site back from an agency is
2183 + * the point — so there is no stronger identity to check. WordPress.com decides, marking a
2184 + * switch `po_signed` only when the signing token belongs to the owner of record.
2185 + *
2186 + * A consumer locking ownership through the filter is a separate refusal that still applies to
2187 + * everybody, so it is re-read here with the anchor out of the way.
2188 + *
2189 + * @since 9.9.0
2190 + *
2191 + * @return bool
2192 + */
2193 + private function current_user_may_move_locked_ownership() {
2194 + $anchor = Protected_Owner::get_locked();
2195 + $user_id = get_current_user_id();
2196 +
2197 + if ( ! $anchor || ! $user_id ) {
2198 + return false;
2199 + }
2200 +
2201 + // Both halves, as everywhere else the binding is trusted: it outlives the token, so a
2202 + // disconnected user can still carry the anchored ID.
2203 + if ( ! $this->is_user_connected( $user_id ) ) {
2204 + return false;
2205 + }
2206 +
2207 + // This user's own binding, never a search for whoever holds the anchored ID, which would
2208 + // hand the site to the first match.
2209 + if ( Utils::get_wpcom_user_id( $user_id ) !== (int) $anchor['wpcom_user_id'] ) {
2210 + return false;
2211 + }
2212 +
2213 + /** This filter is documented in projects/packages/connection/src/class-manager.php */
2214 + return (bool) apply_filters( 'jetpack_connection_ownership_transferable', true );
2215 + }
2216 +
2217 + /**
2218 + * Drop the anchor once the site has left the owner it names.
2219 + *
2220 + * Do not make this clear more eagerly. An anchor dropped while WordPress.com kept its own
2221 + * locks the site to nobody, and `reconcile_protected_owner()` returns before asking when
2222 + * there is no local anchor left to repair it with. The reverse mistake costs nothing.
2223 + *
2224 + * @since 9.9.0
2225 + *
2226 + * @param int $new_owner_id The local user who now holds the connection.
2227 + * @param true|array $accepted What WordPress.com answered the switch with: a report of
2228 + * what it did where available, otherwise a bare `true`.
2229 + */
2230 + private function release_anchor_after_transfer( $new_owner_id, $accepted ) {
2231 + $anchor = Protected_Owner::get_locked();
2232 +
2233 + if ( ! $anchor ) {
2234 + return;
2235 + }
2236 +
2237 + // WordPress.com resolves the new owner itself and knows what it kept, so where it reports
2238 + // what it did, that report is the whole answer.
2239 + if ( is_array( $accepted ) ) {
2240 + if ( ! empty( $accepted['released'] ) ) {
2241 + Protected_Owner::clear();
2242 + }
2243 +
2244 + return;
2245 + }
2246 +
2247 + // A bare `true` says only that the switch happened, leaving who the site went to as the
2248 + // best guess available. A zero is "could not determine", which covers the owner taking the
2249 + // site back — the case WordPress.com keeps its record for.
2250 + $new_owner_wpcom_id = $this->resolve_wpcom_user_id( $new_owner_id );
2251 +
2252 + if ( $new_owner_wpcom_id && $new_owner_wpcom_id !== (int) $anchor['wpcom_user_id'] ) {
2253 + Protected_Owner::clear();
2254 + }
2255 + }
2256 +
2257 + /**
1479 2258 * Request to WPCOM to update the connection owner.
1480 2259 *
1481 2260 * @since 1.29.0
2261 + * @since 9.9.0 Returns what WordPress.com answered rather than casting it, so a
2262 + * report of what the switch did can be read. Still falsy on failure.
1482 2263 *
1483 2264 * @param int $new_owner_id The ID of the user to become the connection owner.
1484 2265 *
1485 - * @return bool Whether the ownership transfer was successful.
2266 + * @return bool|array False if the transfer failed, otherwise what WordPress.com answered:
2267 + * `true`, or a non-empty report such as `array( 'released' => bool )`.
1486 2268 */
1487 2269 public function update_connection_owner_wpcom( $new_owner_id ) {
1488 2270 // Notify WPCOM about the connection owner change.
1489 2271 $xml = new Jetpack_IXR_Client(
@@ -1500,9 +2282,17 @@
1500 2282 if ( $xml->isError() ) {
1501 2283 return false;
1502 2284 }
1503 2285
1504 - return (bool) $xml->getResponse();
2286 + $response = $xml->getResponse();
2287 +
2288 + // An array is the switch reporting what it did, and an empty one reports nothing rather
2289 + // than refusing — a bare `true` by another name. Only a falsy non-array is a refusal.
2290 + if ( is_array( $response ) ) {
2291 + return empty( $response ) ? true : $response;
2292 + }
2293 +
2294 + return (bool) $response;
1505 2295 }
1506 2296
1507 2297 /**
1508 2298 * Returns the requested Jetpack API URL.
@@ -2243,8 +3033,10 @@
2243 3033
2244 3034 /**
2245 3035 * Validate the tokens, and refresh the invalid ones.
2246 3036 *
3037 + * @since 9.8.1 When token validation is inconclusive, check the blog token on its own instead of assuming both are broken.
3038 + *
2247 3039 * @return string|bool|WP_Error True if connection restored or string indicating what's to be done next. A `WP_Error` object or false otherwise.
2248 3040 */
2249 3041 public function restore() {
2250 3042 // If this is a site connection we need to trigger a full reconnection as our only secure means of
@@ -2254,9 +3046,8 @@
2254 3046 }
2255 3047
2256 3048 $validate_tokens_response = $this->get_tokens()->validate();
2257 3049
2258 - // If token validation failed, trigger a full reconnection.
2259 3050 if ( is_array( $validate_tokens_response ) &&
2260 3051 isset( $validate_tokens_response['blog_token']['is_healthy'] ) &&
2261 3052 isset( $validate_tokens_response['user_token']['is_healthy'] ) ) {
2262 3053 $blog_token_healthy = $validate_tokens_response['blog_token']['is_healthy'];
@@ -2261,10 +3052,14 @@
2261 3052 isset( $validate_tokens_response['user_token']['is_healthy'] ) ) {
2262 3053 $blog_token_healthy = $validate_tokens_response['blog_token']['is_healthy'];
2263 3054 $user_token_healthy = $validate_tokens_response['user_token']['is_healthy'];
2264 3055 } else {
2265 - $blog_token_healthy = false;
2266 - $user_token_healthy = false;
3056 + // The paired health check could not run (a token is missing locally — e.g. a
3057 + // deleted owner token — or the request failed): no evidence the blog token is
3058 + // broken, and it's the one credential reconnect() would revoke for every user,
3059 + // so check it on its own before that teardown.
3060 + $blog_token_healthy = true === $this->get_tokens()->validate_blog_token();
3061 + $user_token_healthy = false; // Unknown, treated as unhealthy.
2267 3062 }
2268 3063
2269 3064 // Tokens are both valid, or both invalid. We can't fix the problem we don't see, so the full reconnection is needed.
2270 3065 if ( $blog_token_healthy === $user_token_healthy ) {
@@ -2489,8 +3284,10 @@
2489 3284
2490 3285 /**
2491 3286 * Authorizes the user by obtaining and storing the user token.
2492 3287 *
3288 + * @since 9.8.1 Only a user with `jetpack_connect` can take a vacant connection owner slot.
3289 + *
2493 3290 * @param array $data The request data.
2494 3291 * @return string|\WP_Error Returns a string on success.
2495 3292 * Returns a \WP_Error on failure.
2496 3293 */
@@ -2549,9 +3346,10 @@
2549 3346 if ( ! $token ) {
2550 3347 return new \WP_Error( 'no_token', 'Error generating token.', 400 );
2551 3348 }
2552 3349
2553 - $is_connection_owner = ! $this->has_connected_owner();
3350 + // Only a user who may manage the site connection takes a vacant owner slot; others link as secondary users.
3351 + $is_connection_owner = ! $this->has_connected_owner() && current_user_can( 'jetpack_connect' );
2554 3352
2555 3353 $this->get_tokens()->update_user_token( $current_user_id, sprintf( '%s.%d', $token, $current_user_id ), $is_connection_owner );
2556 3354
2557 3355 // Delete cached connected user data, so a cached failure from the
@@ -3064,14 +3862,65 @@
3064 3862
3065 3863 /**
3066 3864 * Disconnect the user from WP.com, and initiate the reconnect process.
3067 3865 *
3068 - * @return bool
3866 + * @since 9.8.0 Added the `$force` parameter.
3867 + *
3868 + * @param bool $force Whether to remove the local token even if WordPress.com does not confirm the unlink.
3869 + * When false, only the current user's own token is refreshed, never the owner's,
3870 + * and only over a healthy blog token.
3871 + * @return true|string|WP_Error True when forced. Otherwise 'authorize' when the user should authorize again, a `WP_Error` object on failure.
3069 3872 */
3070 - public function refresh_user_token() {
3071 - ( new Tracking() )->record_user_event( 'restore_connection_refresh_user_token' );
3072 - $this->disconnect_user( null, true, true );
3073 - return true;
3873 + public function refresh_user_token( $force = true ) {
3874 + $user_id = get_current_user_id();
3875 +
3876 + if ( ! $force ) {
3877 + // Unlinking the owner would leave the site without one.
3878 + if ( ! $user_id || $this->is_site_connection() || $this->get_connection_owner_id() === $user_id ) {
3879 + return new WP_Error(
3880 + 'restore_requires_administrator',
3881 + __( 'An administrator needs to restore the Jetpack connection.', 'jetpack-connection' ),
3882 + array( 'status' => 403 )
3883 + );
3884 + }
3885 +
3886 + // Relinking goes over the blog token, so it must work before anything is unlinked.
3887 + $blog_token_health = $this->get_tokens()->validate_blog_token();
3888 +
3889 + if ( is_wp_error( $blog_token_health ) ) {
3890 + return new WP_Error(
3891 + 'restore_check_failed',
3892 + __( 'The site connection could not be checked. Please try again shortly.', 'jetpack-connection' ),
3893 + array( 'status' => 503 )
3894 + );
3895 + }
3896 +
3897 + if ( true !== $blog_token_health ) {
3898 + return new WP_Error(
3899 + 'restore_requires_administrator',
3900 + __( 'The site connection is broken. An administrator needs to restore it before you can reconnect your account.', 'jetpack-connection' ),
3901 + array( 'status' => 409 )
3902 + );
3903 + }
3904 + }
3905 +
3906 + // A forced refresh unlinks even without a stored token, as it always has.
3907 + if ( $force || $this->is_user_connected( $user_id ) ) {
3908 + ( new Tracking() )->record_user_event( 'restore_connection_refresh_user_token' );
3909 +
3910 + // Unforced, the local token only goes once WordPress.com has unlinked it.
3911 + $unlinked = $this->disconnect_user( $force ? null : $user_id, $force, $force );
3912 +
3913 + if ( ! $force && ! $unlinked ) {
3914 + return new WP_Error(
3915 + 'restore_unlink_failed',
3916 + __( 'Your account could not be disconnected from WordPress.com. Please try again.', 'jetpack-connection' ),
3917 + array( 'status' => 502 )
3918 + );
3919 + }
3920 + }
3921 +
3922 + return $force ? true : 'authorize';
3074 3923 }
3075 3924
3076 3925 /**
3077 3926 * Fetches a signed token.