PluginProbe
ActivityPub / 9.2.2
ActivityPub v9.2.2
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
activitypub / includes / rest / trait-verification.php

trait-verification.php in ActivityPub 9.2.2, at includes/rest/trait-verification.php

253 lines 9.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Verification Trait file.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub\Rest;
9
10 use Activitypub\Collection\Actors;
11 use Activitypub\OAuth\Scope;
12 use Activitypub\OAuth\Server as OAuth_Server;
13 use Activitypub\Signature;
14
15 use function Activitypub\is_same_host;
16 use function Activitypub\object_to_uri;
17 use function Activitypub\use_authorized_fetch;
18 use function Activitypub\user_can_act_as_blog;
19
20 /**
21 * Verification Trait.
22 *
23 * Provides methods for verifying HTTP Signatures (S2S) and OAuth (C2S).
24 * Controllers can use this trait for permission callbacks.
25 */
26 trait Verification {
27 /**
28 * Verify HTTP Signature for server-to-server requests.
29 *
30 * Verifies the signature of POST, PUT, PATCH, and DELETE requests,
31 * as well as GET requests when authorized fetch is enabled.
32 * HEAD requests are bypassed by default so caches and link-checkers
33 * can probe public endpoints; callers that pass `$force_signature`
34 * (e.g. FEP-8fcf's `/followers/sync`) require signatures on HEAD too.
35 *
36 * @see https://www.w3.org/wiki/SocialCG/ActivityPub/Primer/Authentication_Authorization#Authorized_fetch
37 * @see https://swicg.github.io/activitypub-http-signature/#authorized-fetch
38 *
39 * @param \WP_REST_Request $request The request object.
40 * @param bool $force_signature Optional. When true, GET and HEAD requests also
41 * require a valid signature even with Authorized
42 * Fetch disabled. Use for endpoints that are
43 * peer-only (e.g. FEP-8fcf's `/followers/sync`).
44 * Default false.
45 * @return bool|\WP_Error True if authorized, WP_Error otherwise.
46 */
47 public function verify_signature( $request, $force_signature = false ) {
48 if ( 'HEAD' === $request->get_method() && ! $force_signature ) {
49 return true;
50 }
51
52 /**
53 * Filter to defer signature verification.
54 *
55 * Skip signature verification for debugging purposes or to reduce load for
56 * certain Activity-Types, like "Delete". Callers that want to preserve
57 * mandatory signing for endpoints passing `$force_signature = true`
58 * (e.g. FEP-8fcf's `/followers/sync`) should inspect the third argument
59 * and return `false` in that case.
60 *
61 * @param bool $defer Whether to defer signature verification.
62 * @param \WP_REST_Request $request The request used to generate the response.
63 * @param bool $force_signature Whether the caller has forced signature
64 * verification for this endpoint.
65 * @return bool Whether to defer signature verification.
66 */
67 $defer = \apply_filters( 'activitypub_defer_signature_verification', false, $request, $force_signature );
68
69 if ( $defer ) {
70 return true;
71 }
72
73 // POST-Requests always have to be signed, GET-Requests only require a signature in secure mode or when forced.
74 if ( 'GET' !== $request->get_method() || use_authorized_fetch() || $force_signature ) {
75 $verified_key_id = Signature::verify_http_signature( $request );
76 if ( \is_wp_error( $verified_key_id ) ) {
77 return new \WP_Error(
78 'activitypub_signature_verification',
79 $verified_key_id->get_error_message(),
80 array( 'status' => 401 )
81 );
82 }
83
84 // Verify the signing key's host matches the activity actor's host.
85 $key_id_check = $this->verify_key_id( $request, $verified_key_id );
86 if ( \is_wp_error( $key_id_check ) ) {
87 return $key_id_check;
88 }
89 }
90
91 return true;
92 }
93
94 /**
95 * Check that the signature keyId and activity actor share the same host.
96 *
97 * Binds against the keyId that {@see Signature::verify_http_signature()} actually
98 * verified, passed in by the caller. Re-parsing the headers here would be unsafe: a
99 * request can present several signature labels (or a draft and an RFC 9421 header) with
100 * different keyIds, and only the verifier knows which one validated.
101 *
102 * Fails closed when either side has no parsable host. A non-URL keyId such as
103 * `acct:mallory@attacker.example` resolves to a real key through WebFinger yet yields no host,
104 * so treating "no host" as "nothing to check" would drop the only tie between the signing key
105 * and the claimed actor. Both hosts must be present and equal.
106 *
107 * @since 8.1.0
108 * @since 9.0.0 Added the `$key_id` parameter; binds against the verified keyId.
109 * @since 9.2.1 Reject a keyId or actor with no parsable host instead of allowing it.
110 *
111 * @param \WP_REST_Request $request The request object.
112 * @param string|null $key_id The keyId that verified the signature.
113 * @return true|\WP_Error True if valid, WP_Error on mismatch.
114 */
115 private function verify_key_id( $request, $key_id ) {
116 $json = $request->get_json_params();
117 $actor = isset( $json['actor'] ) ? object_to_uri( $json['actor'] ) : null;
118
119 // A signed request without a body actor (e.g. an authorized-fetch GET) has nothing to bind.
120 if ( ! $actor ) {
121 return true;
122 }
123
124 // The keyId and the actor must resolve to the same host; an identifier with no parsable
125 // host (e.g. an `acct:` keyId) is unbindable and therefore not an authorized one.
126 if ( ! is_same_host( $key_id, $actor ) ) {
127 return new \WP_Error(
128 'activitypub_key_actor_mismatch',
129 \__( 'Signing key and activity actor must be on the same host.', 'activitypub' ),
130 array( 'status' => 403 )
131 );
132 }
133
134 return true;
135 }
136
137 /**
138 * Verify user authentication via OAuth.
139 *
140 * Automatically determines the required scope based on the HTTP method:
141 * - GET, HEAD: read scope
142 * - POST, PUT, PATCH, DELETE: write scope
143 *
144 * If the request has a user_id parameter, also verifies that the
145 * authenticated user matches that actor.
146 *
147 * Application Passwords are not accepted directly on C2S endpoints.
148 *
149 * Security: `check_oauth_permission()` requires a valid Bearer token via
150 * `is_oauth_request()`. Cookie-authenticated sessions never satisfy that
151 * check, so a wp-admin session in another browser tab cannot be hijacked
152 * to drive C2S writes on behalf of the user (no CSRF path on this surface).
153 *
154 * @param \WP_REST_Request $request The request object.
155 * @return bool|\WP_Error True if authorized, WP_Error otherwise.
156 */
157 public function verify_authentication( $request ) {
158 // Determine scope based on HTTP method.
159 $method = $request->get_method();
160 $read_methods = array( 'GET', 'HEAD' );
161 $scope = \in_array( $method, $read_methods, true ) ? Scope::READ : Scope::WRITE;
162
163 $result = OAuth_Server::check_oauth_permission( $request, $scope );
164 if ( true === $result ) {
165 return $this->maybe_verify_owner( $request );
166 }
167
168 return $result;
169 }
170
171 /**
172 * Verify owner if user_id parameter is present.
173 *
174 * @param \WP_REST_Request $request The request object.
175 * @return bool|\WP_Error True if authorized, WP_Error otherwise.
176 */
177 private function maybe_verify_owner( $request ) {
178 $user_id = $request->get_param( 'user_id' );
179
180 if ( null === $user_id ) {
181 return true;
182 }
183
184 return $this->verify_owner( $request );
185 }
186
187 /**
188 * Verify that the authenticated user matches the actor specified in the request.
189 *
190 * Checks that the user_id parameter matches the authenticated user.
191 * Works with both OAuth tokens and WordPress session auth (wp-login.php flow).
192 *
193 * @param \WP_REST_Request $request The request object.
194 * @return bool|\WP_Error True if the user matches, WP_Error otherwise.
195 */
196 public function verify_owner( $request ) {
197 $user_id = $request->get_param( 'user_id' );
198
199 // Validate the user exists.
200 $user = Actors::get_by_id( $user_id );
201 if ( \is_wp_error( $user ) ) {
202 return $user;
203 }
204
205 /*
206 * Require an authenticated session before the identity-equality check below.
207 * Without this guard, anonymous requests with `user_id = 0` (blog actor)
208 * would match because `\get_current_user_id()` also returns `0`, exposing
209 * owner-only behaviors such as the hidden social graph for the blog actor.
210 */
211 if ( ! \is_user_logged_in() ) {
212 return new \WP_Error(
213 'activitypub_forbidden',
214 \__( 'You can only access your own resources.', 'activitypub' ),
215 array( 'status' => 403 )
216 );
217 }
218
219 if ( \get_current_user_id() === (int) $user_id ) {
220 return true;
221 }
222
223 // The blog actor has no `wp_users` row, so the identity-equality check above
224 // cannot match for a logged-in user. Delegate to the capability helper.
225 if ( Actors::BLOG_USER_ID === (int) $user_id && user_can_act_as_blog() ) {
226 return true;
227 }
228
229 return new \WP_Error(
230 'activitypub_forbidden',
231 \__( 'You can only access your own resources.', 'activitypub' ),
232 array( 'status' => 403 )
233 );
234 }
235
236 /**
237 * Check if the social graph should be shown for this request.
238 *
239 * Returns true if the social graph setting allows public display,
240 * or if the request is authenticated by the resource owner.
241 *
242 * @since 8.1.0
243 *
244 * @param \WP_REST_Request $request The request object.
245 * @return bool True if the social graph should be shown.
246 */
247 protected function show_social_graph( $request ) {
248 $user_id = $request->get_param( 'user_id' );
249
250 return Actors::show_social_graph( $user_id ) || true === $this->verify_owner( $request );
251 }
252 }
253