PluginProbe
ActivityPub / 9.0.1
ActivityPub v9.0.1
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.0.1, at includes/rest/trait-verification.php

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