auth = new TokenAuth( $connections ); $this->connections = $connections; } /** Admin session only, for browser endpoints that must never accept token auth from a remote site. */ public function is_admin(): bool|WP_Error { if ( current_user_can( 'manage_options' ) ) { return true; } return new WP_Error( 'shiftora_forbidden', __( 'You do not have permission to perform this action.', 'shiftora' ), [ 'status' => 403 ] ); } /** Any active connection: the token, then the per-request HMAC signature against replay and tampering. */ public function require_auth( WP_REST_Request $request ): bool|WP_Error { $connection = $this->auth->authenticate( $request ); if ( null === $connection ) { return new WP_Error( 'shiftora_unauthorized', __( 'Invalid or missing authentication credentials.', 'shiftora' ), [ 'status' => 401 ] ); } $verification = $this->verify_hmac_signature( $request, $connection ); if ( is_wp_error( $verification ) ) { return $verification; } // Handlers read the resolved connection from here. $request->set_param( '_shiftora_connection', $connection ); return true; } public static function signable_path( string $route, string $query ): string { return '' !== $query ? $route . '?' . $query : $route; } /** Checks the X-Shiftora-Timestamp, -Body-Hash and -Signature headers against the shared secret. */ private function verify_hmac_signature( WP_REST_Request $request, object $connection ): bool|WP_Error { $client_timestamp = (string) $request->get_header( 'X-Shiftora-Timestamp' ); $client_body_hash = (string) $request->get_header( 'X-Shiftora-Body-Hash' ); $client_signature = (string) $request->get_header( 'X-Shiftora-Signature' ); // Required: the timestamp window and body hash bound what a token can do, so // skipping the check would make a token seen in a log a permanent credential. // Tested for emptiness, since a missing header reads differently across // environments. The body hash is exempt: a bodiless request signs an empty one. if ( '' === $client_signature || '' === $client_timestamp ) { return new WP_Error( 'shiftora_signature_missing', __( 'Request is not signed.', 'shiftora' ), [ 'status' => 401 ] ); } $now = time(); if ( abs( (int) $client_timestamp - $now ) > 300 ) { return new WP_Error( 'shiftora_signature_expired', __( 'Request signature has expired.', 'shiftora' ), [ 'status' => 401 ] ); } // Route plus original query string; the admin-ajax proxy hands the forwarded one over // through ForwardedQuery, so both transports sign the same string. $query = ForwardedQuery::current(); if ( null === $query ) { return new WP_Error( 'shiftora_signature_mismatch', __( 'Request signature is invalid.', 'shiftora' ), [ 'status' => 401 ] ); } $signable_path = self::signable_path( $request->get_route(), $query ); // get_body() is null for a bodiless request (every GET and proxied call) and // hash() rejects null on PHP 8; a missing and an empty body sign the same way. $request_body = (string) $request->get_body(); $recomputed_hash = '' === $request_body ? '' : hash( 'sha256', $request_body, false ); if ( ! hash_equals( $client_body_hash, $recomputed_hash ) ) { return new WP_Error( 'shiftora_signature_mismatch', __( 'Request body hash does not match. The request may have been tampered with.', 'shiftora' ), [ 'status' => 401 ] ); } // Constant-time compare, using the credential this site issued to the caller, // which MigrationRequestAuthenticator signed with on the other end. $secret = $this->connections->get_remote_token( $connection ); $payload = strtoupper( $request->get_method() ) . "\n" . $signable_path . "\n" . $client_timestamp . "\n" . $recomputed_hash; $expected = hash_hmac( 'sha256', $payload, $secret, false ); if ( ! hash_equals( $expected, $client_signature ) ) { return new WP_Error( 'shiftora_signature_mismatch', __( 'Request signature is invalid.', 'shiftora' ), [ 'status' => 401 ] ); } return true; } /** A pull-capable token or an admin session; the browser calls these while preparing a migration. */ public function require_pull_or_admin( WP_REST_Request $request ): bool|WP_Error { $has_token = null !== $request->get_header( 'X-Shiftora-Token' ) || null !== $request->get_header( 'X-Shiftora-Key-Id' ); $result = $this->require_pull( $request ); if ( is_wp_error( $result ) ) { if ( $has_token ) { return $result; } return $this->is_admin(); } return true; } /** A paired site's token reads what this site offers, and nothing else: no route a token reaches writes here. */ public function require_pull( WP_REST_Request $request ): bool|WP_Error { $auth_result = $this->require_auth( $request ); if ( is_wp_error( $auth_result ) ) { return $auth_result; } if ( ! (bool) get_option( 'shiftora_allow_pull', true ) ) { return new WP_Error( 'shiftora_forbidden', __( 'This site is not allowing other sites to pull data from it right now.', 'shiftora' ), [ 'status' => 403 ] ); } // A row saved while pushes existed as push_only was denied reads on purpose, and // ignoring the column would hand it every export route. $connection = $request->get_param( '_shiftora_connection' ); if ( 'push_only' === ( $connection->direction ?? '' ) ) { return new WP_Error( 'shiftora_forbidden', __( 'This connection does not have pull permission.', 'shiftora' ), [ 'status' => 403 ] ); } return true; } }