Skip to content

Implement XML-RPC endpoint discovery #680

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Apr 21, 2025
Merged
Show file tree
Hide file tree
Changes from 3 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
21 changes: 14 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ quote = "1.0"
regex = "1.11"
reqwest = "0.12.15"
rocket = "0.5"
roxmltree = "0.20"
rstest = "0.24"
rstest_reuse = "0.7.0"
rustls = "0.23.26"
Expand Down
1 change: 1 addition & 0 deletions wp_api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ parse_link_header = { workspace = true }
paste = { workspace = true }
regex = { workspace = true }
reqwest = { workspace = true, features = [ "multipart", "json", "stream", "gzip", "brotli", "zstd", "deflate", "rustls-tls", "hickory-dns" ], optional = true }
roxmltree = { workspace = true }
rustls = { workspace = true, optional = true }
scraper = { workspace = true }
serde = { workspace = true, features = [ "derive", "rc" ] }
Expand Down
29 changes: 22 additions & 7 deletions wp_api/src/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,16 +109,23 @@ impl WpApiDetails {

/// Does the site use a plugin that disables application passwords?
pub fn has_application_password_blocking_plugin(&self) -> bool {
KnownApplicationPasswordBlockingPlugin::all()
KnownAuthenticationBlockingPlugin::application_passwords()
.iter()
.any(|plugin| self.namespaces.contains(&plugin.namespace))
}

/// Returns a list of plugins that might be responsible for disabling application passwords.
pub fn application_password_blocking_plugins(
&self,
) -> Vec<KnownApplicationPasswordBlockingPlugin> {
KnownApplicationPasswordBlockingPlugin::all()
pub fn application_password_blocking_plugins(&self) -> Vec<KnownAuthenticationBlockingPlugin> {
KnownAuthenticationBlockingPlugin::application_passwords()
.iter()
.filter(|plugin| self.namespaces.contains(&plugin.namespace))
.cloned()
.collect()
}

/// Returns a list of plugins that might be responsible for disabling XML-RPC.
pub fn xmlrpc_blocking_plugins(&self) -> Vec<KnownAuthenticationBlockingPlugin> {
KnownAuthenticationBlockingPlugin::xmlrpc()
.iter()
.filter(|plugin| self.namespaces.contains(&plugin.namespace))
.cloned()
Expand All @@ -138,7 +145,7 @@ impl WpApiDetails {
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, uniffi::Record)]
pub struct KnownApplicationPasswordBlockingPlugin {
pub struct KnownAuthenticationBlockingPlugin {
/// The name of the plugin.
pub name: String,
/// The plugin's REST API namespace.
Expand All @@ -147,7 +154,7 @@ pub struct KnownApplicationPasswordBlockingPlugin {
pub support_url: String,
}

impl KnownApplicationPasswordBlockingPlugin {
impl KnownAuthenticationBlockingPlugin {
fn all() -> Vec<Self> {
vec![
Self {
Expand All @@ -170,6 +177,14 @@ impl KnownApplicationPasswordBlockingPlugin {
},
]
}

fn application_passwords() -> Vec<Self> {
Self::all()
}

fn xmlrpc() -> Vec<Self> {
Self::all()
}
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, uniffi::Record)]
Expand Down
125 changes: 123 additions & 2 deletions wp_api/src/login/login_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,20 @@ use super::{
self, API_ROOT_LINK_HEADER, ApiRootUrl, ApplicationPasswordsNotSupportedReason,
AutoDiscoveryAttempt, AutoDiscoveryAttemptFailure, AutoDiscoveryAttemptResult,
AutoDiscoveryAttemptSuccess, AutoDiscoveryResult, FetchAndParseApiRootFailure,
FindApiRootFailure, ParseHomepageResult,
FindApiRootFailure, ParseHomepageResult, XMLRPCDisabledReason, XMLRPCDiscoveryError,
extract_rsd_url, is_xmlrpc_response, parse_rsd_for_xmlrpc,
},
};
use crate::{
ParsedUrl, RequestExecutionError, WpError,
middleware::{PerformsRequests, WpApiMiddlewarePipeline},
request::{
RequestExecutor, RequestMethod, ResponseBodyType, WpNetworkHeaderMap, WpNetworkRequest,
WpNetworkResponse,
WpNetworkRequestBody, WpNetworkResponse,
endpoint::{WP_JSON_PATH_SEGMENTS, WpEndpointUrl},
},
};
use itertools::Itertools;
use std::sync::Arc;
use uuid::Uuid;

Expand Down Expand Up @@ -331,6 +333,125 @@ impl WpLoginClient {
}
})
}

pub async fn xmlrpc_discovery(
&self,
details: AutoDiscoveryAttemptSuccess,
) -> Result<ParsedUrl, XMLRPCDiscoveryError> {
let mut candidates: Vec<ParsedUrl> = vec![];
// Prioritize discovered XML-RPC URL if it's available from the site.
if let Ok(url) = self.xmlrpc_from_rsd(&details.parsed_site_url).await {
candidates.push(url);
}
// Fallback to the default XML-RPC URL.
candidates.push(
details
.parsed_site_url
.by_extending_and_splitting_by_forward_slash(["xmlrpc.php"])
.into(),
);
candidates.dedup();

let mut failures: Vec<XMLRPCDiscoveryError> = vec![];
for candidate in candidates {
match self
.validate_xmlrpc_url(&candidate, &details.api_details)
.await
{
Ok(_) => return Ok(candidate),
Err(error) => {
failures.push(error);
}
}
}

Err(failures
.into_iter()
.sorted_by(|a, b| b.importance().cmp(&a.importance()))
.next()
.expect("There is at least one failure"))
}

async fn validate_xmlrpc_url(
&self,
url: &ParsedUrl,
api_details: &WpApiDetails,
) -> Result<(), XMLRPCDiscoveryError> {
let response = self.perform(
WpNetworkRequest {
uuid: Uuid::new_v4().into(),
retry_count: 0,
method: RequestMethod::POST,
url: WpEndpointUrl(url.url()),
header_map: WpNetworkHeaderMap::default().into(),
body: Some(Arc::new(WpNetworkRequestBody::new(r#"<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName></methodCall>"#.as_bytes().to_vec()))),
}
.into(),
)
.await
// It's very likely xml-rpc is blocked by the hosting provider (the request has not reached to WordPress),
// if the site does not send any valid HTTP response.
.map_err(|_| XMLRPCDiscoveryError::Disabled { reason: XMLRPCDisabledReason::ByHost })?;

// 200 status code and a valid XML-RPC response indicates that XML-RPC is enabled.
// All other responses indicate that XML-RPC is disabled.
if response.status_code == 200 && is_xmlrpc_response(&response.body_as_string()) {
return Ok(());
}

let mut plugins = api_details.xmlrpc_blocking_plugins();
let reason = match plugins.len() {
0 => XMLRPCDisabledReason::ByHost,
1 => XMLRPCDisabledReason::ByPlugin {
plugin: plugins.pop().expect("Already verified there is one plugin"),
},
_ => XMLRPCDisabledReason::ByMultiplePlugins,
};
Err(XMLRPCDiscoveryError::Disabled { reason })
}

async fn xmlrpc_from_rsd(
&self,
parsed_site_url: &ParsedUrl,
) -> Result<ParsedUrl, XMLRPCDiscoveryError> {
let response = self
.perform(
WpNetworkRequest {
uuid: Uuid::new_v4().into(),
retry_count: 0,
method: RequestMethod::GET,
url: WpEndpointUrl(parsed_site_url.url()),
header_map: WpNetworkHeaderMap::default().into(),
body: None,
}
.into(),
)
.await
.map_err(|error| XMLRPCDiscoveryError::FetchHomepage { error })?;

let rsd_url = extract_rsd_url(&response.body_as_string())
.ok_or(XMLRPCDiscoveryError::EndpointNotFound)?;

let rsd_response = self
.perform(
WpNetworkRequest {
uuid: Uuid::new_v4().into(),
retry_count: 0,
method: RequestMethod::GET,
url: WpEndpointUrl(rsd_url),
header_map: WpNetworkHeaderMap::default().into(),
body: None,
}
.into(),
)
.await
.map_err(|_| XMLRPCDiscoveryError::Disabled {
reason: XMLRPCDisabledReason::ByHost,
})?;

parse_rsd_for_xmlrpc(&rsd_response.body_as_string())
.ok_or(XMLRPCDiscoveryError::EndpointNotFound)
}
}

impl PerformsRequests for WpLoginClient {
Expand Down
Loading