Merge branch 'signature_verification' of https://github.com/mediaformat/wordpress-activitypub into pr/299

This commit is contained in:
Matthias Pfefferle 2023-05-02 09:50:11 +02:00
commit 958b712e5b
7 changed files with 140 additions and 55 deletions

View file

@ -57,6 +57,9 @@ function init() {
require_once \dirname( __FILE__ ) . '/includes/rest/class-following.php'; require_once \dirname( __FILE__ ) . '/includes/rest/class-following.php';
Rest\Following::init(); Rest\Following::init();
require_once \dirname( __FILE__ ) . '/includes/rest/class-server.php';
\Activitypub\Rest\Server::init();
require_once \dirname( __FILE__ ) . '/includes/rest/class-webfinger.php'; require_once \dirname( __FILE__ ) . '/includes/rest/class-webfinger.php';
Rest\Webfinger::init(); Rest\Webfinger::init();

View file

@ -142,6 +142,15 @@ class Admin {
'default' => array( 'post', 'pages' ), 'default' => array( 'post', 'pages' ),
) )
); );
\register_setting(
'activitypub',
'activitypub_use_secure_mode',
array(
'type' => 'boolean',
'description' => \__( 'Secure mode allows blocking servers from fetching public activities', 'activitypub' ),
'default' => 0,
)
);
} }
public static function add_settings_help_tab() { public static function add_settings_help_tab() {

View file

@ -110,14 +110,23 @@ class Signature {
} }
} }
/**
* Verifies the http signatures
*
* @param WP_REQUEST | Array $request
* @return void
* @author Django Doucet
*/
public static function verify_http_signature( $request ) { public static function verify_http_signature( $request ) {
$headers = $request->get_headers(); $headers = $request->get_headers();
$actor = isset( json_decode( $request->get_body() )->actor ) ? json_decode( $request->get_body() )->actor : '';
$headers['(request-target)'][0] = strtolower( $request->get_method() ) . ' /wp-json' . $request->get_route();
if ( ! $headers ) { if ( ! $headers ) {
$headers = self::default_server_headers(); return new \WP_Error( 'activitypub_signature', 'Request not signed', array( 'status' => 403 ) );
} }
$actor = isset( json_decode( $request->get_body() )->actor ) ? json_decode( $request->get_body() )->actor : '';
$headers['(request-target)'][0] = strtolower( $request->get_method() ) . ' /' . rest_get_url_prefix() . $request->get_route();
if ( array_key_exists( 'signature', $headers ) ) { if ( array_key_exists( 'signature', $headers ) ) {
$signature_block = self::parse_signature_header( $headers['signature'] ); $signature_block = self::parse_signature_header( $headers['signature'] );
} elseif ( array_key_exists( 'authorization', $headers ) ) { } elseif ( array_key_exists( 'authorization', $headers ) ) {
@ -125,7 +134,7 @@ class Signature {
} }
if ( ! isset( $signature_block ) || ! $signature_block ) { if ( ! isset( $signature_block ) || ! $signature_block ) {
return false; return new \WP_Error( 'activitypub_signature', 'Incompatible request signature. keyId and signature are required', array( 'status' => 403 ) );
} }
$signed_headers = $signature_block['headers']; $signed_headers = $signature_block['headers'];
@ -135,12 +144,12 @@ class Signature {
$signed_data = self::get_signed_data( $signed_headers, $signature_block, $headers ); $signed_data = self::get_signed_data( $signed_headers, $signature_block, $headers );
if ( ! $signed_data ) { if ( ! $signed_data ) {
return false; return new \WP_Error( 'activitypub_signature', 'Signed request date outside acceptable time window', array( 'status' => 403 ) );
} }
$algorithm = self::get_signature_algorithm( $signature_block ); $algorithm = self::get_signature_algorithm( $signature_block );
if ( ! $algorithm ) { if ( ! $algorithm ) {
return false; return new \WP_Error( 'activitypub_signature', 'Unsupported signature algorithm (only rsa-sha256 and hs2019 are supported)', array( 'status' => 403 ) );
} }
if ( \in_array( 'digest', $signed_headers, true ) && isset( $body ) ) { if ( \in_array( 'digest', $signed_headers, true ) && isset( $body ) ) {
@ -156,41 +165,49 @@ class Signature {
} }
if ( \base64_encode( \hash( $hashalg, $body, true ) ) !== $digest[1] ) { // phpcs:ignore if ( \base64_encode( \hash( $hashalg, $body, true ) ) !== $digest[1] ) { // phpcs:ignore
return false; return new \WP_Error( 'activitypub_signature', 'Invalid Digest header', array( 'status' => 403 ) );
} }
} }
$public_key = \rtrim( \Activitypub\get_publickey_by_actor( $actor, $signature_block['keyId'] ) ); // phpcs:ignore $public_key = \Activitypub\get_publickey_by_actor( $actor, $signature_block['keyId'] ); // phpcs:ignore
if ( \is_wp_error( $public_key ) ) {
return \openssl_verify( $signed_data, $signature_block['signature'], $public_key, $algorithm ) > 0; return $public_key;
} else {
$public_key = \rtrim( $public_key );
}
$verified = \openssl_verify( $signed_data, $signature_block['signature'], $public_key, $algorithm ) > 0;
if ( ! $verified ) {
return new \WP_Error( 'activitypub_signature', 'Invalid signature', array( 'status' => 403 ) ); // phpcs:ignore null coalescing operator
}
} }
public static function default_server_headers() { /**
$headers = array( * Gets the signature algorithm from the signature header
'(request-target)' => strtolower( $_SERVER['REQUEST_METHOD'] ) . ' ' . $_SERVER['REQUEST_URI'], *
'content-type' => $_SERVER['CONTENT_TYPE'], * @param array $signature_block
'content-length' => $_SERVER['CONTENT_LENGTH'], * @return string algorithm
); * @author Django Doucet
foreach ( $_SERVER as $k => $v ) { */
if ( \strpos( $k, 'HTTP_' ) === 0 ) {
$field = \str_replace( '_', '-', \strtolower( \substr( $k, 5 ) ) );
$headers[ $field ] = $v;
}
}
return $headers;
}
public static function get_signature_algorithm( $signature_block ) { public static function get_signature_algorithm( $signature_block ) {
if ( $signature_block['algorithm'] ) {
switch ( $signature_block['algorithm'] ) { switch ( $signature_block['algorithm'] ) {
case 'rsa-sha-512': case 'rsa-sha-512':
return 'sha512'; return 'sha512'; //hs2019 https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12
default: default:
return 'sha256'; return 'sha256';
} }
}
return false; return false;
} }
/**
* Parses the Signature header
*
* @param array $header
* @return array signature parts
* @author Django Doucet <django.doucet@webdevstudios.com>
*/
public static function parse_signature_header( $header ) { public static function parse_signature_header( $header ) {
$ret = array(); $ret = array();
$matches = array(); $matches = array();
@ -222,6 +239,15 @@ class Signature {
return $ret; return $ret;
} }
/**
* Gets the header data from the included pseudo headers
*
* @param array $signed_headers
* @param array $signature_block (pseudo-headers)
* @param array $headers (original http headers)
* @return signed headers for comparison
* @author Django Doucet
*/
public static function get_signed_data( $signed_headers, $signature_block, $headers ) { public static function get_signed_data( $signed_headers, $signature_block, $headers ) {
$signed_data = ''; $signed_data = '';
// This also verifies time-based values by returning false if any of these are out of range. // This also verifies time-based values by returning false if any of these are out of range.

View file

@ -219,15 +219,6 @@ function get_publickey_by_actor( $actor, $key_id ) {
return new \WP_Error( 'activitypub_no_public_key', \__( 'No "Public-Key" found', 'activitypub' ), $metadata ); return new \WP_Error( 'activitypub_no_public_key', \__( 'No "Public-Key" found', 'activitypub' ), $metadata );
} }
function get_actor_from_key( $key_id ) {
$actor = \strip_fragment_from_url( $key_id );
if ( $actor === $key_id ) {
// strip /main-key/ for GoToSocial.
$actor = \dirname( $key_id );
}
return $actor;
}
function get_follower_inboxes( $user_id, $cc = array() ) { function get_follower_inboxes( $user_id, $cc = array() ) {
$followers = \Activitypub\Peer\Followers::get_followers( $user_id ); $followers = \Activitypub\Peer\Followers::get_followers( $user_id );
$followers = array_merge( $followers, $cc ); $followers = array_merge( $followers, $cc );

View file

@ -115,6 +115,7 @@ class Inbox {
* @return WP_REST_Response * @return WP_REST_Response
*/ */
public static function user_inbox_post( $request ) { public static function user_inbox_post( $request ) {
$user_id = $request->get_param( 'user_id' ); $user_id = $request->get_param( 'user_id' );
$data = $request->get_params(); $data = $request->get_params();
@ -135,6 +136,7 @@ class Inbox {
* @return WP_REST_Response * @return WP_REST_Response
*/ */
public static function shared_inbox_post( $request ) { public static function shared_inbox_post( $request ) {
$data = $request->get_params(); $data = $request->get_params();
$type = $request->get_param( 'type' ); $type = $request->get_param( 'type' );
$users = self::extract_recipients( $data ); $users = self::extract_recipients( $data );
@ -214,16 +216,6 @@ class Inbox {
'sanitize_callback' => 'esc_url_raw', 'sanitize_callback' => 'esc_url_raw',
); );
$params['signature'] = array(
'required' => true,
'validate_callback' => function( $param, $request, $key ) {
if ( ! Signature::verify_http_signature( $request ) ) {
return false; // returns http 400 rest_invalid_param
}
return $param;
},
);
$params['actor'] = array( $params['actor'] = array(
'required' => true, 'required' => true,
'sanitize_callback' => function( $param, $request, $key ) { 'sanitize_callback' => function( $param, $request, $key ) {
@ -266,12 +258,6 @@ class Inbox {
'required' => true, 'required' => true,
'type' => 'string', 'type' => 'string',
'sanitize_callback' => 'esc_url_raw', 'sanitize_callback' => 'esc_url_raw',
'validate_callback' => function( $param, $request, $key ) {
if ( ! Signature::verify_http_signature( $request ) ) {
return false;
}
return $param;
},
); );
$params['actor'] = array( $params['actor'] = array(

View file

@ -0,0 +1,55 @@
<?php
namespace Activitypub\Rest;
use Activitypub\Signature;
/**
* ActivityPub Server REST-Class
*
* @author Django Doucet
*
* @see https://www.w3.org/TR/activitypub/#security-verification
*/
class Server {
/**
* Initialize the class, registering WordPress hooks
*/
public static function init() {
\add_filter( 'rest_request_before_callbacks', array( '\Activitypub\Rest\Server', 'authorize_activitypub_requests' ), 10, 3 );
}
/**
* Callback function to authorize each api requests
*
* @see \WP_REST_Request
*
* @param $response
* @param $handler
* @param \WP_REST_Request $request
*
* @return mixed|\WP_Error
*/
public static function authorize_activitypub_requests( $response, $handler, $request ) {
$maybe_activitypub = $request->get_route();
if ( str_starts_with( $maybe_activitypub, '/activitypub' ) ) {
if ( 'POST' === $request->get_method() ) {
$verified_request = Signature::verify_http_signature( $request );
if ( \is_wp_error( $verified_request ) ) {
return $verified_request;
}
} else {
// SecureMode/Authorized fetch.
$secure_mode = \get_option( 'activitypub_use_secure_mode', '0' );
if ( $secure_mode ) {
if ( \is_wp_error( $verified_request ) ) {
return $verified_request;
}
}
}
}
}
}

View file

@ -171,6 +171,21 @@
</tbody> </tbody>
</table> </table>
<table class="form-table">
<tbody>
<tr>
<th scope="row">
<?php \esc_html_e( 'Secure Mode', 'activitypub' ); ?>
</th>
<td>
<p>
<label><input type="checkbox" name="activitypub_use_secure_mode" id="activitypub_use_secure_mode" value="1" <?php echo \checked( '1', \get_option( 'activitypub_use_secure_mode', '0' ) ); ?> /> <?php echo wp_kses( \__( 'In secure mode every ActivityPub request must be signed. This disallows anonymous requests and prevents serving content to servers listed in the Blocklist.', 'activitypub' ), 'default' ); ?></label>
</p>
</td>
</tr>
</tbody>
</table>
<?php \do_settings_fields( 'activitypub', 'server' ); ?> <?php \do_settings_fields( 'activitypub', 'server' ); ?>
<?php \do_settings_sections( 'activitypub' ); ?> <?php \do_settings_sections( 'activitypub' ); ?>