Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/changelog/fix-rfc9421-original-host
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: fixed

Fixed signed requests from other servers being rejected on sites behind a proxy that changes the host name.
18 changes: 14 additions & 4 deletions includes/signature/class-http-message-signature.php
Original file line number Diff line number Diff line change
Expand Up @@ -453,11 +453,17 @@ private function get_component_values( $components, $headers ) {
$key = \strtok( $component, ';' ); // See https://www.rfc-editor.org/rfc/rfc9421.html#name-query-parameters.
$key = \strtolower( \trim( $key, '"' ) );

// These name this server through the client-supplied `Host`, so check the value that enters the base.
/*
* These name this server through the client-supplied `Host`, so check the value that enters the base.
* A proxy that rewrites `Host` leaves the value the peer signed only in `X-Original-Host`, so it is read first.
*/
$host = '';
if ( \in_array( $key, array( '@target-uri', '@authority', 'host' ), true ) ) {
$host = 'host' === $key ? ( $headers['host'][0] ?? '' ) : ( $_SERVER['HTTP_HOST'] ?? '' );

if ( ! is_signature_host( $host ) ) {
if ( is_signature_host( $headers['x_original_host'][0] ?? '' ) ) {
$host = $headers['x_original_host'][0];
} elseif ( ! is_signature_host( $host ) ) {
return new \WP_Error( 'activitypub_signature_host', 'The signature is not made out to this site.' );
}
}
Expand All @@ -468,11 +474,15 @@ private function get_component_values( $components, $headers ) {
break;

case '@target-uri':
$value = \set_url_scheme( '//' . ( $_SERVER['HTTP_HOST'] ?? '' ) . ( $_SERVER['REQUEST_URI'] ?? '/' ) );
$value = \set_url_scheme( \sprintf( '//%s%s', $host, $_SERVER['REQUEST_URI'] ?? '/' ) );
break;

case '@authority':
$value = $_SERVER['HTTP_HOST'] ?? '';
$value = $host;
break;

case 'host':
$value = \preg_replace( '/\s+/', ' ', \trim( $host ) );
break;

case '@scheme':
Expand Down
57 changes: 57 additions & 0 deletions tests/phpunit/tests/includes/class-test-signature.php
Original file line number Diff line number Diff line change
Expand Up @@ -2193,6 +2193,63 @@ public function test_verify_http_signature_rfc9421_binds_the_host_field() {
$this->assertNotWPError( $own, 'A signature over this site\'s host field still verifies.' );
}

/**
* RFC 9421: a signature made out to this site still verifies when a proxy rewrote Host.
*
* @covers ::verify_http_signature
* @covers \Activitypub\Signature\Http_Message_Signature::get_component_values
*/
public function test_verify_http_signature_rfc9421_accepts_our_own_original_host() {
\update_option( 'activitypub_rfc9421_signature', '1' );
$mock = $this->mock_remote_key();

$own = \wp_parse_url( \home_url(), \PHP_URL_HOST );

$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['REQUEST_URI'] = '/wp-json/activitypub/1.0/inbox';
$_SERVER['HTTP_HOST'] = 'internal.local';
$_SERVER['HTTPS'] = 'on';

// The peer signed our public host; a proxy then rewrote `Host` to an internal name.
$request = $this->build_replayed_request( $own, 'internal.local', $own );
$result = Signature::verify_http_signature( $request );

\remove_filter( 'activitypub_pre_http_get_remote_object', $mock );
\delete_option( 'activitypub_rfc9421_signature' );

$this->assertNotWPError( $result, 'A signature made out to this site must still verify when a proxy rewrote Host.' );
}

/**
* RFC 9421: a foreign X-Original-Host is ignored, so Host has to name this site.
*
* @covers ::verify_http_signature
* @covers \Activitypub\Signature\Http_Message_Signature::get_component_values
*/
public function test_verify_http_signature_rfc9421_rejects_a_foreign_original_host() {
\update_option( 'activitypub_rfc9421_signature', '1' );
$mock = $this->mock_remote_key();

$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['REQUEST_URI'] = '/wp-json/activitypub/1.0/inbox';
$_SERVER['HTTPS'] = 'on';

// Arriving with our host, so the foreign header falls back to it and the signature no longer matches.
$_SERVER['HTTP_HOST'] = 'example.org';
$own_host = Signature::verify_http_signature( $this->build_replayed_request( 'evil.example', 'example.org', 'evil.example' ) );

// Arriving with an internal name, nothing names this site.
$_SERVER['HTTP_HOST'] = 'internal.local';
$internal = Signature::verify_http_signature( $this->build_replayed_request( 'evil.example', 'internal.local', 'evil.example' ) );

\remove_filter( 'activitypub_pre_http_get_remote_object', $mock );
\delete_option( 'activitypub_rfc9421_signature' );

$this->assertWPError( $own_host, 'A signature made out to another host must not verify, whatever X-Original-Host claims.' );
$this->assertWPError( $internal, 'A foreign X-Original-Host must not stand in for this site.' );
$this->assertSame( 'activitypub_signature_host', $internal->get_error_code() );
}

/**
* The reverse-proxy case the header exists for keeps working.
*
Expand Down
Loading