Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.00% covered (success)
90.00%
45 / 50
60.00% covered (warning)
60.00%
3 / 5
CRAP
n/a
0 / 0
jetpack_activitypub_reader_auth_check_permission
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
10
jetpack_activitypub_reader_auth_is_oauth_request
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
jetpack_activitypub_reader_auth_is_jetpack_signed
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
jetpack_activitypub_reader_auth_is_blog_mode
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
jetpack_activitypub_reader_auth_is_target_route
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
7
1<?php
2/**
3 * Compatibility shim so Jetpack-signed admin requests can reach the
4 * ActivityPub plugin's auth-gated client-to-server endpoints, used by the
5 * Jetpack-connected site's wp.com Reader to read the timeline and publish
6 * notes.
7 *
8 * Scope:
9 * - Three routes, with method affinity (inbox GET, proxy POST, outbox POST).
10 * - Blog-mode AP sites only; user-mode is out of scope.
11 * - Real OAuth flows are never overridden — when a Bearer is present we
12 *   defer to the plugin's normal verification.
13 *
14 * @package automattic/jetpack
15 */
16
17declare( strict_types = 1 );
18
19use Automattic\Jetpack\Connection\Manager as Connection_Manager;
20use Automattic\Jetpack\Connection\Rest_Authentication;
21use Automattic\Jetpack\Status;
22use Automattic\Jetpack\Status\Host;
23
24if ( ! defined( 'ABSPATH' ) ) {
25    exit( 0 );
26}
27
28// The upstream filter passes a third `$scope` arg; the shim deliberately
29// drops it (`accepted_args = 2`) because a Jetpack-signed admin grants full
30// client-to-server access by design.
31add_filter( 'activitypub_oauth_check_permission', 'jetpack_activitypub_reader_auth_check_permission', 10, 2 );
32
33/**
34 * Filter callback for `activitypub_oauth_check_permission`.
35 *
36 * Returns `true` to authorise the request without an AP OAuth bearer when
37 * every scope predicate holds. Returns the incoming `$result` (typically
38 * null) otherwise, letting the plugin's normal OAuth check run.
39 *
40 * `$request` is typed `mixed` rather than `\WP_REST_Request` because the
41 * WordPress filter ABI provides no guarantee — `is_target_route()` performs
42 * the shape check before any method is dispatched on the argument.
43 *
44 * @since 15.9
45 *
46 * @param mixed $result  Result from a previous filter, or null.
47 * @param mixed $request The REST request being checked, expected to be a `\WP_REST_Request`.
48 * @return mixed `true` when authorised; `$result` otherwise.
49 */
50function jetpack_activitypub_reader_auth_check_permission( $result, $request ) {
51    if ( null !== $result ) {
52        return $result;
53    }
54
55    // Only run on sites where the wp.com Reader actually needs the bridge:
56    // connected, non-offline Jetpack sites that aren't wpcom Simple. Simple
57    // sites already share the AP OAuth datastore with the plugin and pass the
58    // standard verify_authentication path.
59    if (
60        ( new Host() )->is_wpcom_simple()
61        || ! ( new Connection_Manager() )->is_connected()
62        || ( new Status() )->is_offline_mode()
63    ) {
64        return $result;
65    }
66
67    // A real OAuth client beat us here. Let the plugin handle it normally.
68    if ( jetpack_activitypub_reader_auth_is_oauth_request() ) {
69        return $result;
70    }
71
72    if ( ! jetpack_activitypub_reader_auth_is_target_route( $request ) ) {
73        return $result;
74    }
75
76    if ( ! jetpack_activitypub_reader_auth_is_jetpack_signed() ) {
77        return $result;
78    }
79
80    // Must follow the signing check: Rest_Authentication installs the wpcom
81    // user on user-token signed requests, so the current user is only trustworthy
82    // after that gate has passed.
83    if ( ! current_user_can( 'manage_options' ) ) {
84        return $result;
85    }
86
87    if ( ! jetpack_activitypub_reader_auth_is_blog_mode() ) {
88        return $result;
89    }
90
91    return true;
92}
93
94/**
95 * Whether the current request carries a verified AP OAuth bearer.
96 *
97 * Wrapped so the `Server` class absence in non-AP environments is a clean
98 * `false` rather than a fatal.
99 *
100 * @since 15.9
101 *
102 * @return bool
103 */
104function jetpack_activitypub_reader_auth_is_oauth_request(): bool {
105    if ( ! class_exists( 'Activitypub\OAuth\Server' ) ) {
106        return false;
107    }
108    return \Activitypub\OAuth\Server::is_oauth_request();
109}
110
111/**
112 * Whether the current request was Jetpack-signed (blog or user token).
113 *
114 * Both signing flavours are accepted: the wpcom bridge signs outbound calls
115 * with the user's Jetpack token when one is available and falls back to the
116 * blog token otherwise. Either is sufficient evidence the call originated
117 * from a wpcom shadow request the destination already trusts.
118 *
119 * @since 15.9
120 *
121 * @return bool
122 */
123function jetpack_activitypub_reader_auth_is_jetpack_signed(): bool {
124    if ( ! class_exists( Rest_Authentication::class ) ) {
125        return false;
126    }
127    return Rest_Authentication::is_signed_with_user_token()
128        || Rest_Authentication::is_signed_with_blog_token();
129}
130
131/**
132 * Whether the destination AP plugin is configured to expose a blog actor.
133 *
134 * Accepts both `'blog'` (blog-only) and `'actor_blog'` (per-user + blog).
135 * On `'actor_blog'` sites the blog actor behaves identically to pure
136 * blog-mode and is the only actor the wpcom Reader operates on — the
137 * route patterns are pinned to `user_id=0`, so widening the grant to
138 * arbitrary user actors is not possible here.
139 *
140 * Pure user-mode (`'actor'`) is still rejected: the blog actor doesn't
141 * exist on those sites, so authorizing `user_id=0` routes would be
142 * nonsensical.
143 *
144 * Uses a `null` sentinel default so an unset option is treated as
145 * "unknown, deny" rather than implicitly accepted — the AP plugin's own
146 * option default is `ACTIVITYPUB_ACTOR_MODE` (i.e. `'actor'`), so
147 * falling back to a blog-accepting mode here would silently widen the
148 * grant surface on fresh installs.
149 *
150 * @since 15.9
151 *
152 * @return bool
153 */
154function jetpack_activitypub_reader_auth_is_blog_mode(): bool {
155    $mode = get_option( 'activitypub_actor_mode', null );
156    return 'blog' === $mode || 'actor_blog' === $mode;
157}
158
159/**
160 * Whether the request targets one of the three Reader auth-gated routes.
161 *
162 * Each pattern is anchored to the AP namespace and includes a method affinity,
163 * so callers can't widen the shim by sending an unexpected verb at an allowed
164 * path (e.g. POSTing to inbox).
165 *
166 * @since 15.9
167 *
168 * @param \WP_REST_Request $request The REST request.
169 * @return bool
170 */
171function jetpack_activitypub_reader_auth_is_target_route( $request ): bool {
172    if ( ! is_object( $request )
173        || ! method_exists( $request, 'get_route' )
174        || ! method_exists( $request, 'get_method' )
175    ) {
176        return false;
177    }
178
179    $route  = (string) $request->get_route();
180    $method = strtoupper( (string) $request->get_method() );
181
182    // Patterns are pinned to the blog actor (user_id 0) on purpose: the wpcom
183    // Reader only operates on the blog actor, and granting the OAuth bypass
184    // for arbitrary user ids would silently widen the surface if the AP
185    // plugin ever loosened its downstream `verify_owner` check.
186    static $patterns = array(
187        'GET'  => array(
188            '#^/activitypub/\d+\.\d+/(?:users|actors)/0/inbox/?$#',
189        ),
190        'POST' => array(
191            '#^/activitypub/\d+\.\d+/proxy/?$#',
192            '#^/activitypub/\d+\.\d+/(?:users|actors)/0/outbox/?$#',
193        ),
194    );
195
196    if ( ! isset( $patterns[ $method ] ) ) {
197        return false;
198    }
199
200    foreach ( $patterns[ $method ] as $pattern ) {
201        if ( preg_match( $pattern, $route ) ) {
202            return true;
203        }
204    }
205
206    return false;
207}