248 lines
8.3 KiB
PHP
248 lines
8.3 KiB
PHP
<?php
|
|
/**
|
|
* Verification Trait file.
|
|
*
|
|
* @package Activitypub
|
|
*/
|
|
|
|
namespace Activitypub\Rest;
|
|
|
|
use Activitypub\Collection\Actors;
|
|
use Activitypub\OAuth\Scope;
|
|
use Activitypub\OAuth\Server as OAuth_Server;
|
|
use Activitypub\Signature;
|
|
|
|
use function Activitypub\object_to_uri;
|
|
use function Activitypub\use_authorized_fetch;
|
|
use function Activitypub\user_can_act_as_blog;
|
|
|
|
/**
|
|
* Verification Trait.
|
|
*
|
|
* Provides methods for verifying HTTP Signatures (S2S) and OAuth (C2S).
|
|
* Controllers can use this trait for permission callbacks.
|
|
*/
|
|
trait Verification {
|
|
/**
|
|
* Verify HTTP Signature for server-to-server requests.
|
|
*
|
|
* Verifies the signature of POST, PUT, PATCH, and DELETE requests,
|
|
* as well as GET requests when authorized fetch is enabled.
|
|
* HEAD requests are bypassed by default so caches and link-checkers
|
|
* can probe public endpoints; callers that pass `$force_signature`
|
|
* (e.g. FEP-8fcf's `/followers/sync`) require signatures on HEAD too.
|
|
*
|
|
* @see https://www.w3.org/wiki/SocialCG/ActivityPub/Primer/Authentication_Authorization#Authorized_fetch
|
|
* @see https://swicg.github.io/activitypub-http-signature/#authorized-fetch
|
|
*
|
|
* @param \WP_REST_Request $request The request object.
|
|
* @param bool $force_signature Optional. When true, GET and HEAD requests also
|
|
* require a valid signature even with Authorized
|
|
* Fetch disabled. Use for endpoints that are
|
|
* peer-only (e.g. FEP-8fcf's `/followers/sync`).
|
|
* Default false.
|
|
* @return bool|\WP_Error True if authorized, WP_Error otherwise.
|
|
*/
|
|
public function verify_signature( $request, $force_signature = false ) {
|
|
if ( 'HEAD' === $request->get_method() && ! $force_signature ) {
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Filter to defer signature verification.
|
|
*
|
|
* Skip signature verification for debugging purposes or to reduce load for
|
|
* certain Activity-Types, like "Delete". Callers that want to preserve
|
|
* mandatory signing for endpoints passing `$force_signature = true`
|
|
* (e.g. FEP-8fcf's `/followers/sync`) should inspect the third argument
|
|
* and return `false` in that case.
|
|
*
|
|
* @param bool $defer Whether to defer signature verification.
|
|
* @param \WP_REST_Request $request The request used to generate the response.
|
|
* @param bool $force_signature Whether the caller has forced signature
|
|
* verification for this endpoint.
|
|
* @return bool Whether to defer signature verification.
|
|
*/
|
|
$defer = \apply_filters( 'activitypub_defer_signature_verification', false, $request, $force_signature );
|
|
|
|
if ( $defer ) {
|
|
return true;
|
|
}
|
|
|
|
// POST-Requests always have to be signed, GET-Requests only require a signature in secure mode or when forced.
|
|
if ( 'GET' !== $request->get_method() || use_authorized_fetch() || $force_signature ) {
|
|
$verified_request = Signature::verify_http_signature( $request );
|
|
if ( \is_wp_error( $verified_request ) ) {
|
|
return new \WP_Error(
|
|
'activitypub_signature_verification',
|
|
$verified_request->get_error_message(),
|
|
array( 'status' => 401 )
|
|
);
|
|
}
|
|
|
|
// Verify the signing key's host matches the activity actor's host.
|
|
$key_id_check = $this->verify_key_id( $request );
|
|
if ( \is_wp_error( $key_id_check ) ) {
|
|
return $key_id_check;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Check that the signature keyId and activity actor share the same host.
|
|
*
|
|
* @since 8.1.0
|
|
*
|
|
* @param \WP_REST_Request $request The request object.
|
|
* @return true|\WP_Error True if valid, WP_Error on mismatch.
|
|
*/
|
|
private function verify_key_id( $request ) {
|
|
$sig = $request->get_header( 'signature' );
|
|
if ( ! $sig || ! \preg_match( '/keyId="([^"]+)"/i', $sig, $m ) ) {
|
|
// RFC 9421 Signature-Input.
|
|
$sig = $request->get_header( 'signature-input' );
|
|
if ( ! $sig || ! \preg_match( '/keyid="([^"]+)"/i', $sig, $m ) ) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
$key_host = \strtolower( (string) \wp_parse_url( $m[1], \PHP_URL_HOST ) );
|
|
$json = $request->get_json_params();
|
|
$actor = isset( $json['actor'] ) ? object_to_uri( $json['actor'] ) : null;
|
|
|
|
if ( ! $actor || ! $key_host ) {
|
|
return true;
|
|
}
|
|
|
|
$actor_host = \strtolower( (string) \wp_parse_url( $actor, \PHP_URL_HOST ) );
|
|
|
|
if ( ! $actor_host || $key_host !== $actor_host ) {
|
|
return new \WP_Error(
|
|
'activitypub_key_actor_mismatch',
|
|
\__( 'Signing key and activity actor must be on the same host.', 'activitypub' ),
|
|
array( 'status' => 403 )
|
|
);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Verify user authentication via OAuth.
|
|
*
|
|
* Automatically determines the required scope based on the HTTP method:
|
|
* - GET, HEAD: read scope
|
|
* - POST, PUT, PATCH, DELETE: write scope
|
|
*
|
|
* If the request has a user_id parameter, also verifies that the
|
|
* authenticated user matches that actor.
|
|
*
|
|
* Application Passwords are not accepted directly on C2S endpoints.
|
|
*
|
|
* Security: `check_oauth_permission()` requires a valid Bearer token via
|
|
* `is_oauth_request()`. Cookie-authenticated sessions never satisfy that
|
|
* check, so a wp-admin session in another browser tab cannot be hijacked
|
|
* to drive C2S writes on behalf of the user (no CSRF path on this surface).
|
|
*
|
|
* @param \WP_REST_Request $request The request object.
|
|
* @return bool|\WP_Error True if authorized, WP_Error otherwise.
|
|
*/
|
|
public function verify_authentication( $request ) {
|
|
// Determine scope based on HTTP method.
|
|
$method = $request->get_method();
|
|
$read_methods = array( 'GET', 'HEAD' );
|
|
$scope = \in_array( $method, $read_methods, true ) ? Scope::READ : Scope::WRITE;
|
|
|
|
$result = OAuth_Server::check_oauth_permission( $request, $scope );
|
|
if ( true === $result ) {
|
|
return $this->maybe_verify_owner( $request );
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Verify owner if user_id parameter is present.
|
|
*
|
|
* @param \WP_REST_Request $request The request object.
|
|
* @return bool|\WP_Error True if authorized, WP_Error otherwise.
|
|
*/
|
|
private function maybe_verify_owner( $request ) {
|
|
$user_id = $request->get_param( 'user_id' );
|
|
|
|
if ( null === $user_id ) {
|
|
return true;
|
|
}
|
|
|
|
return $this->verify_owner( $request );
|
|
}
|
|
|
|
/**
|
|
* Verify that the authenticated user matches the actor specified in the request.
|
|
*
|
|
* Checks that the user_id parameter matches the authenticated user.
|
|
* Works with both OAuth tokens and WordPress session auth (wp-login.php flow).
|
|
*
|
|
* @param \WP_REST_Request $request The request object.
|
|
* @return bool|\WP_Error True if the user matches, WP_Error otherwise.
|
|
*/
|
|
public function verify_owner( $request ) {
|
|
$user_id = $request->get_param( 'user_id' );
|
|
|
|
// Validate the user exists.
|
|
$user = Actors::get_by_id( $user_id );
|
|
if ( \is_wp_error( $user ) ) {
|
|
return $user;
|
|
}
|
|
|
|
/*
|
|
* Require an authenticated session before the identity-equality check below.
|
|
* Without this guard, anonymous requests with `user_id = 0` (blog actor)
|
|
* would match because `\get_current_user_id()` also returns `0`, exposing
|
|
* owner-only behaviors such as the hidden social graph for the blog actor.
|
|
*/
|
|
if ( ! \is_user_logged_in() ) {
|
|
return new \WP_Error(
|
|
'activitypub_forbidden',
|
|
\__( 'You can only access your own resources.', 'activitypub' ),
|
|
array( 'status' => 403 )
|
|
);
|
|
}
|
|
|
|
if ( \get_current_user_id() === (int) $user_id ) {
|
|
return true;
|
|
}
|
|
|
|
// The blog actor has no `wp_users` row, so the identity-equality check above
|
|
// cannot match for a logged-in user. Delegate to the capability helper.
|
|
if ( Actors::BLOG_USER_ID === (int) $user_id && user_can_act_as_blog() ) {
|
|
return true;
|
|
}
|
|
|
|
return new \WP_Error(
|
|
'activitypub_forbidden',
|
|
\__( 'You can only access your own resources.', 'activitypub' ),
|
|
array( 'status' => 403 )
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check if the social graph should be shown for this request.
|
|
*
|
|
* Returns true if the social graph setting allows public display,
|
|
* or if the request is authenticated by the resource owner.
|
|
*
|
|
* @since 8.1.0
|
|
*
|
|
* @param \WP_REST_Request $request The request object.
|
|
* @return bool True if the social graph should be shown.
|
|
*/
|
|
protected function show_social_graph( $request ) {
|
|
$user_id = $request->get_param( 'user_id' );
|
|
|
|
return Actors::show_social_graph( $user_id ) || true === $this->verify_owner( $request );
|
|
}
|
|
}
|