PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 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 All 508 releases
← All changes | jetpack_vendor/automattic/jetpack-connection/src/class-manager.php +300 -27 16.3-a.5 → 16.3 View file →
@@ -1591,18 +1591,12 @@
1591 1591 * @param int $anchored_wpcom_user_id The WordPress.com identity this site has anchored.
1592 1592 * @return array|null The record, or null when WordPress.com could not answer.
1593 1593 */
1594 1594 protected function query_protected_owner_record( $anchored_wpcom_user_id ) {
1595 - $xml = new Jetpack_IXR_Client( array( 'user_id' => get_current_user_id() ) );
1596 - $xml->query( 'jetpack.reconcileProtectedOwner', array( 'anchored_wpcom_user_id' => $anchored_wpcom_user_id ) );
1597 -
1598 - if ( $xml->isError() ) {
1599 - return null;
1600 - }
1601 -
1602 - $response = $xml->getResponse();
1603 -
1604 - return is_array( $response ) ? $response : null;
1595 + return $this->request_protected_owner_record(
1596 + '/reconcile',
1597 + array( 'anchored_wpcom_user_id' => (int) $anchored_wpcom_user_id )
1598 + );
1605 1599 }
1606 1600
1607 1601 /**
1608 1602 * Claim this site's protected ownership for the current user with WordPress.com.
@@ -1614,18 +1608,53 @@
1614 1608 *
1615 1609 * @return array|null The record, or null when WordPress.com could not answer.
1616 1610 */
1617 1611 protected function assert_protected_owner_record() {
1618 - $xml = new Jetpack_IXR_Client( array( 'user_id' => get_current_user_id() ) );
1619 - $xml->query( 'jetpack.assertProtectedOwner' );
1612 + return $this->request_protected_owner_record();
1613 + }
1620 1614
1621 - if ( $xml->isError() ) {
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 ) ) {
1622 1651 return null;
1623 1652 }
1624 1653
1625 - $response = $xml->getResponse();
1654 + $record = json_decode( wp_remote_retrieve_body( $response ), true );
1626 1655
1627 - return is_array( $response ) ? $response : null;
1656 + return is_array( $record ) ? $record : null;
1628 1657 }
1629 1658
1630 1659 /**
1631 1660 * Record a user as the protected owner and promote them to connection owner.
@@ -1672,8 +1701,14 @@
1672 1701 array( 'status' => 400 )
1673 1702 );
1674 1703 }
1675 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 +
1676 1711 // WordPress.com is asked before anything is written here. It owns the record, so a claim it
1677 1712 // has not accepted must not leave a locked anchor behind on this site.
1678 1713 $record = $this->assert_protected_owner_record();
1679 1714
@@ -1689,13 +1724,9 @@
1689 1724
1690 1725 // Somebody else already holds this site. Beyond support there is no way past this, which is
1691 1726 // the point: an owner that could be overwritten by the next claimant protects nobody.
1692 1727 if ( 'locked_to_other' === $record['status'] ) {
1693 - return new WP_Error(
1694 - 'protected_owner_claimed_by_other',
1695 - __( 'This site is already protected by a different WordPress.com account. Contact support.', 'jetpack-connection' ),
1696 - array( 'status' => 409 )
1697 - );
1728 + return $this->protected_owner_claimed_by_other();
1698 1729 }
1699 1730
1700 1731 // Only an accepted claim is anchored: any other verdict is refused, even one carrying an ID.
1701 1732 if ( ! in_array( $record['status'], array( 'recorded', 'already_yours' ), true ) || empty( $record['wpcom_user_id'] ) ) {
@@ -1705,8 +1736,13 @@
1705 1736 array( 'status' => 400 )
1706 1737 );
1707 1738 }
1708 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 +
1709 1745 // Store the binding the anchor will be compared against, so the gate reads local state from
1710 1746 // here on. Routed through the deduping writer, which clears the ID off any previous holder.
1711 1747 Utils::set_wpcom_user_id( $user_id, (int) $record['wpcom_user_id'] );
1712 1748
@@ -1725,8 +1761,141 @@
1725 1761 return true;
1726 1762 }
1727 1763
1728 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 + /**
1729 1898 * Drop the protected owner anchor, unlocking ownership.
1730 1899 *
1731 1900 * Gated on `jetpack_connect` like establishing one, releasing a lock being the more
1732 1901 * consequential half. The `@internal` tag is documentation; the capability is enforcement.
@@ -1933,8 +2102,10 @@
1933 2102 * Update the connection owner.
1934 2103 *
1935 2104 * @since 1.29.0
1936 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.
1937 2108 *
1938 2109 * @param int $new_owner_id The ID of the user to become the connection owner.
1939 2110 *
1940 2111 * @return true|WP_Error True if owner successfully changed, WP_Error otherwise.
@@ -1941,9 +2112,9 @@
1941 2112 */
1942 2113 public function update_connection_owner( $new_owner_id ) {
1943 2114 // Answered before the arguments are validated: no candidate is valid while ownership is
1944 2115 // locked, and an argument error would suggest a retry that cannot work.
1945 - if ( ! $this->is_ownership_transferable() ) {
2116 + if ( ! $this->is_ownership_transferable() && ! $this->current_user_may_move_locked_ownership() ) {
1946 2117 return new WP_Error(
1947 2118 'ownership_locked',
1948 2119 __( 'The connection owner is locked on this site.', 'jetpack-connection' ),
1949 2120 array( 'status' => 403 )
@@ -1987,8 +2158,10 @@
1987 2158
1988 2159 // Clear the memoized connection owner ID since it changed
1989 2160 self::$connection_owner_id = null;
1990 2161
2162 + $this->release_anchor_after_transfer( $new_owner_id, $owner_updated_wpcom );
2163 +
1991 2164 // Track it.
1992 2165 ( new Tracking() )->record_user_event( 'set_connection_owner_success' );
1993 2166
1994 2167 return true;
@@ -2000,15 +2173,99 @@
2000 2173 );
2001 2174 }
2002 2175
2003 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 + /**
2004 2258 * Request to WPCOM to update the connection owner.
2005 2259 *
2006 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.
2007 2263 *
2008 2264 * @param int $new_owner_id The ID of the user to become the connection owner.
2009 2265 *
2010 - * @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 )`.
2011 2268 */
2012 2269 public function update_connection_owner_wpcom( $new_owner_id ) {
2013 2270 // Notify WPCOM about the connection owner change.
2014 2271 $xml = new Jetpack_IXR_Client(
@@ -2025,9 +2282,17 @@
2025 2282 if ( $xml->isError() ) {
2026 2283 return false;
2027 2284 }
2028 2285
2029 - 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;
2030 2295 }
2031 2296
2032 2297 /**
2033 2298 * Returns the requested Jetpack API URL.
@@ -2768,8 +3033,10 @@
2768 3033
2769 3034 /**
2770 3035 * Validate the tokens, and refresh the invalid ones.
2771 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 + *
2772 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.
2773 3040 */
2774 3041 public function restore() {
2775 3042 // If this is a site connection we need to trigger a full reconnection as our only secure means of
@@ -2779,9 +3046,8 @@
2779 3046 }
2780 3047
2781 3048 $validate_tokens_response = $this->get_tokens()->validate();
2782 3049
2783 - // If token validation failed, trigger a full reconnection.
2784 3050 if ( is_array( $validate_tokens_response ) &&
2785 3051 isset( $validate_tokens_response['blog_token']['is_healthy'] ) &&
2786 3052 isset( $validate_tokens_response['user_token']['is_healthy'] ) ) {
2787 3053 $blog_token_healthy = $validate_tokens_response['blog_token']['is_healthy'];
@@ -2786,10 +3052,14 @@
2786 3052 isset( $validate_tokens_response['user_token']['is_healthy'] ) ) {
2787 3053 $blog_token_healthy = $validate_tokens_response['blog_token']['is_healthy'];
2788 3054 $user_token_healthy = $validate_tokens_response['user_token']['is_healthy'];
2789 3055 } else {
2790 - $blog_token_healthy = false;
2791 - $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.
2792 3062 }
2793 3063
2794 3064 // Tokens are both valid, or both invalid. We can't fix the problem we don't see, so the full reconnection is needed.
2795 3065 if ( $blog_token_healthy === $user_token_healthy ) {
@@ -3014,8 +3284,10 @@
3014 3284
3015 3285 /**
3016 3286 * Authorizes the user by obtaining and storing the user token.
3017 3287 *
3288 + * @since 9.8.1 Only a user with `jetpack_connect` can take a vacant connection owner slot.
3289 + *
3018 3290 * @param array $data The request data.
3019 3291 * @return string|\WP_Error Returns a string on success.
3020 3292 * Returns a \WP_Error on failure.
3021 3293 */
@@ -3074,9 +3346,10 @@
3074 3346 if ( ! $token ) {
3075 3347 return new \WP_Error( 'no_token', 'Error generating token.', 400 );
3076 3348 }
3077 3349
3078 - $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' );
3079 3352
3080 3353 $this->get_tokens()->update_user_token( $current_user_id, sprintf( '%s.%d', $token, $current_user_id ), $is_connection_owner );
3081 3354
3082 3355 // Delete cached connected user data, so a cached failure from the