diff --git a/Cargo.lock b/Cargo.lock index c2df2c126..dd8782d6d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -748,7 +748,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1657,7 +1657,7 @@ checksum = "e19b23d53f35ce9f56aebc7d1bb4e6ac1e9c0db7ac85c8d1760c04379edced37" dependencies = [ "hermit-abi 0.4.0", "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1753,7 +1753,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" dependencies = [ "cfg-if", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] @@ -2327,7 +2327,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2676,6 +2676,12 @@ dependencies = [ "uncased", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rstest" version = "0.24.0" @@ -2754,7 +2760,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3289,7 +3295,7 @@ dependencies = [ "getrandom 0.3.1", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4151,7 +4157,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] @@ -4472,6 +4478,7 @@ dependencies = [ "paste", "regex", "reqwest", + "roxmltree", "rstest", "rstest_reuse", "rustls", diff --git a/Cargo.toml b/Cargo.toml index 6d1327159..0bbef4f0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/wp_api/Cargo.toml b/wp_api/Cargo.toml index 04252ce85..a3306f31e 100644 --- a/wp_api/Cargo.toml +++ b/wp_api/Cargo.toml @@ -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" ] } diff --git a/wp_api/src/login.rs b/wp_api/src/login.rs index f4bcf7bea..3e2fce532 100644 --- a/wp_api/src/login.rs +++ b/wp_api/src/login.rs @@ -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::all() + pub fn application_password_blocking_plugins(&self) -> Vec { + 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::xmlrpc() .iter() .filter(|plugin| self.namespaces.contains(&plugin.namespace)) .cloned() @@ -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. @@ -147,7 +154,7 @@ pub struct KnownApplicationPasswordBlockingPlugin { pub support_url: String, } -impl KnownApplicationPasswordBlockingPlugin { +impl KnownAuthenticationBlockingPlugin { fn all() -> Vec { vec![ Self { @@ -170,6 +177,14 @@ impl KnownApplicationPasswordBlockingPlugin { }, ] } + + fn application_passwords() -> Vec { + Self::all() + } + + fn xmlrpc() -> Vec { + Self::all() + } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, uniffi::Record)] diff --git a/wp_api/src/login/login_client.rs b/wp_api/src/login/login_client.rs index 3ceac8fcb..6fdd5dfa8 100644 --- a/wp_api/src/login/login_client.rs +++ b/wp_api/src/login/login_client.rs @@ -4,7 +4,8 @@ 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::{ @@ -12,10 +13,11 @@ use crate::{ 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; @@ -331,6 +333,125 @@ impl WpLoginClient { } }) } + + pub async fn xmlrpc_discovery( + &self, + details: AutoDiscoveryAttemptSuccess, + ) -> Result { + let mut candidates: Vec = 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 = 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#"system.listMethods"#.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 { + 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 { diff --git a/wp_api/src/login/url_discovery.rs b/wp_api/src/login/url_discovery.rs index 7d70a7f56..13fc451cb 100644 --- a/wp_api/src/login/url_discovery.rs +++ b/wp_api/src/login/url_discovery.rs @@ -1,7 +1,7 @@ use super::WpApiDetails; use crate::{ ParseUrlError, ParsedUrl, RequestExecutionError, RequestExecutionErrorReason, WpErrorCode, - login::KnownApplicationPasswordBlockingPlugin, + login::KnownAuthenticationBlockingPlugin, request::{ResponseBodyType, WpRedirect}, }; use itertools::Itertools; @@ -513,7 +513,7 @@ impl FetchAndParseApiRootFailure { #[derive(Debug, Clone, PartialEq, uniffi::Enum)] pub enum ApplicationPasswordsNotSupportedReason { ApplicationPasswordBlockedByPlugin { - plugin: KnownApplicationPasswordBlockingPlugin, + plugin: KnownAuthenticationBlockingPlugin, }, ApplicationPasswordBlockedByMultiplePlugins, SiteIsLocalDevelopmentEnvironment, @@ -689,6 +689,80 @@ impl WpSupportsLocalization for FetchApiDetailsError { } } +#[derive(Debug, thiserror::Error, uniffi::Error, WpDeriveLocalizable)] +pub enum XmlrpcDiscoveryError { + FetchHomepage { error: RequestExecutionError }, + EndpointNotFound, + Disabled { reason: XmlrpcDisabledReason }, +} + +#[derive(Debug, uniffi::Enum)] +pub enum XmlrpcDisabledReason { + ByHost, + ByPlugin { + plugin: KnownAuthenticationBlockingPlugin, + }, + ByMultiplePlugins, +} + +impl WpSupportsLocalization for XmlrpcDiscoveryError { + fn message_bundle(&self) -> MessageBundle { + match self { + XmlrpcDiscoveryError::FetchHomepage { error } => error.message_bundle(), + XmlrpcDiscoveryError::EndpointNotFound => WpMessages::xmlrpc_endpoint_not_found(), + XmlrpcDiscoveryError::Disabled { reason } => match reason { + XmlrpcDisabledReason::ByHost => WpMessages::xmlrpc_disabled_by_host(), + XmlrpcDisabledReason::ByPlugin { plugin } => WpMessages::xmlrpc_disabled_by_plugin( + plugin.name.clone(), + plugin.namespace.clone(), + ), + XmlrpcDisabledReason::ByMultiplePlugins => { + WpMessages::xmlrpc_disabled_by_multiple_plugins() + } + }, + } + } +} + +impl XmlrpcDiscoveryError { + pub(crate) fn importance(&self) -> NonZeroUsize { + match self { + XmlrpcDiscoveryError::FetchHomepage { .. } => NonZero::new(1), + XmlrpcDiscoveryError::EndpointNotFound => NonZero::new(2), + XmlrpcDiscoveryError::Disabled { .. } => NonZero::new(3), + } + .expect("All values are valid") + } +} + +pub(crate) fn extract_rsd_url(html: &str) -> Option { + let selector = + Selector::parse("link[rel='EditURI'][type='application/rsd+xml'][title='RSD']").ok()?; + Html::parse_document(html) + .select(&selector) + .next()? + .value() + .attr("href") + .map(String::from) +} + +pub(crate) fn parse_rsd_for_xmlrpc(rsd_xml: &str) -> Option { + // The `rsd_xml` is typically from `/xmlrpc.php?rsd` + + roxmltree::Document::parse(rsd_xml) + .ok()? + .descendants() + .find(|n| n.has_tag_name("api") && n.attribute("name") == Some("WordPress"))? + .attribute("apiLink") + .and_then(|s| ParsedUrl::parse(s).ok()) +} + +pub(crate) fn is_xmlrpc_response(body: &str) -> bool { + roxmltree::Document::parse(body) + .map(|doc| doc.root_element().has_tag_name("methodResponse")) + .unwrap_or(false) +} + #[cfg(test)] mod tests { use super::AutoDiscoveryAttempt as A; @@ -813,6 +887,96 @@ mod tests { assert_eq!(first.compare_importance(&second), expected); } + #[test] + fn test_parse_rsd_content() { + let content = r#" + + + + WordPress + https://wordpress.org/ + https://example.com + + + + + + + + + + "#.trim(); + let parsed_url = parse_rsd_for_xmlrpc(content); + assert_eq!( + parsed_url, + Some(ParsedUrl::parse("https://example.com/xmlrpc.php").unwrap()) + ); + } + + #[test] + fn test_xmlrpc_response_success() { + let body = r#" + + + + + + + + system.multicall + system.listMethods + system.getCapabilities + + + + + + + "# + .trim(); + assert!(is_xmlrpc_response(body)); + } + + #[test] + fn test_xmlrpc_response_fault() { + let body = r#" + + + + + + + faultCode + -32601 + + + faultString + server error. requested method system.listMethos does not exist. + + + + + + "#.trim(); + assert!(is_xmlrpc_response(body)); + } + + #[test] + fn test_xmlrpc_response_not_xml() { + let body = r#" + + + Not XML + + +

This is not an XML-RPC response.

+ + + "# + .trim(); + assert!(!is_xmlrpc_response(body)); + } + // `adaf` refers to `AutoDiscoveryAttemptFailure` mod adaf_helpers { use crate::login::WpApiDetailsAuthenticationMap; diff --git a/wp_api_integration_tests/tests/test_login_remote.rs b/wp_api_integration_tests/tests/test_login_remote.rs index 581b69e8a..b58ade0c6 100644 --- a/wp_api_integration_tests/tests/test_login_remote.rs +++ b/wp_api_integration_tests/tests/test_login_remote.rs @@ -1,12 +1,13 @@ use serial_test::parallel; use std::{path::Path, sync::Arc}; use wp_api::{ - InvalidSslErrorReason, RequestExecutionError, RequestExecutionErrorReason, + InvalidSslErrorReason, ParsedUrl, RequestExecutionError, RequestExecutionErrorReason, login::{ login_client::WpLoginClient, url_discovery::{ ApplicationPasswordsNotSupportedReason, AutoDiscoveryAttemptFailure, - FetchAndParseApiRootFailure, FindApiRootFailure, + FetchAndParseApiRootFailure, FindApiRootFailure, XmlrpcDisabledReason, + XmlrpcDiscoveryError, }, }, middleware::{ @@ -352,6 +353,43 @@ async fn login_spec_17_invalid_https_with_exception_works() { ); } +#[tokio::test] +#[parallel] +async fn login_spec_18_xmlrpc_disabled_by_host() { + // The xmlrpc endpoint does not return a valid HTTP response: + // $ curl https://xmlrpc-disabled.wpmt.co/xmlrpc.php + // curl: (92) HTTP/2 stream 1 was not closed cleanly: PROTOCOL_ERROR (err 1) + let result = xmlrpc_url("https://xmlrpc-disabled.wpmt.co").await; + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + XmlrpcDiscoveryError::Disabled { + reason: XmlrpcDisabledReason::ByHost + } + )); +} + +#[tokio::test] +#[parallel] +async fn login_spec_18_xmlrpc_disabled_by_plugin() { + let result = xmlrpc_url("https://xmlrpc-disabled-by-plugin.wpmt.co").await; + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + XmlrpcDiscoveryError::Disabled { + reason: XmlrpcDisabledReason::ByPlugin { .. } + } + )); +} + +#[tokio::test] +#[parallel] +async fn login_spec_18_xmlrpc_found() { + let result = xmlrpc_url("https://vanilla.wpmt.co").await; + assert!(result.is_ok()); + assert_eq!(result.unwrap().url(), "https://vanilla.wpmt.co/xmlrpc.php"); +} + async fn login_url(site_url: &str) -> String { login_url_with_executor(Arc::new(ReqwestRequestExecutor::default()), site_url).await } @@ -449,3 +487,15 @@ async fn discovery_helper( }) .map_err(|e| e.clone()) } + +async fn xmlrpc_url(site_url: &str) -> Result { + let client = WpLoginClient::new( + Arc::new(ReqwestRequestExecutor::default()), + Arc::new(WpApiMiddlewarePipeline { + middlewares: vec![], + }), + ); + let result = client.api_discovery(site_url.to_string()).await; + let success = result.combined_result().unwrap(); + client.xmlrpc_discovery(success.clone()).await +} diff --git a/wp_localization/localization/en-US/main.ftl b/wp_localization/localization/en-US/main.ftl index 35e1ce92b..712ba3c68 100644 --- a/wp_localization/localization/en-US/main.ftl +++ b/wp_localization/localization/en-US/main.ftl @@ -53,6 +53,10 @@ wordpress_org_api_client_error_request_encoding = Failed to encode request. Reas probably_not_wordpress_site = The site does not appear to be a WordPress site. rest_api_disabled = The site's REST API is disabled. Please update your site settings to enable REST API. +xmlrpc_disabled_by_host = The site's XML-RPC is disabled. Please contact your hosting provider to solve this problem. +xmlrpc_disabled_by_plugin = The site's XML-RPC is disabled – the {$plugin} plugin might have disabled XML-RPC. Please visit {$support_url} to learn more. +xmlrpc_disabled_by_multiple_plugins = The site's XML-RPC is disabled – there are multiple installed plugins that might have disabled XML-RPC. Please disable them and try again. +xmlrpc_endpoint_not_found = The site's XML-RPC endpoint could not be found. Please check your site settings and try again. application_password_blocked_by_plugin = Unable to login to {$url} – the {$plugin} plugin might have disabled Application Passwords. Please visit {$support_url} to learn more. application_password_blocked_by_multiple_plugins = Unable to login to {$url} – there are multiple installed plugins that might have disabled Application Passwords. Please disable them and try again. diff --git a/wp_localization/localization/tr-TR/main.ftl b/wp_localization/localization/tr-TR/main.ftl index 23a4584d8..750d12178 100644 --- a/wp_localization/localization/tr-TR/main.ftl +++ b/wp_localization/localization/tr-TR/main.ftl @@ -54,6 +54,11 @@ probably_not_wordpress_site = Site bir WordPress sitesi gibi görünmüyor. rest_api_disabled = Sitenin REST API'si devre dışı. Lütfen REST API'yi etkinleştirmek için site ayarlarınızı güncelleyin. +xmlrpc_disabled_by_host = Sitenin XML-RPC özelliği devre dışı bırakılmış. Lütfen bu sorunu çözmek için barındırma sağlayıcınızla iletişime geçin. +xmlrpc_disabled_by_plugin = Sitenin XML-RPC özelliği devre dışı – {$plugin} eklentisi XML-RPC'yi devre dışı bırakmış olabilir. Daha fazla bilgi için {$support_url} adresini ziyaret edin. +xmlrpc_disabled_by_multiple_plugins = Sitenin XML-RPC özelliği devre dışı – XML-RPC'yi devre dışı bırakmış olabilecek birden fazla eklenti yüklü. Lütfen bu eklentileri devre dışı bırakıp tekrar deneyin. +xmlrpc_endpoint_not_found = Sitenin XML-RPC uç noktası bulunamadı. Lütfen site ayarlarınızı kontrol edip tekrar deneyin. + application_password_blocked_by_plugin = {$url} adresine giriş yapılamıyor – {$plugin} eklentisi Uygulama Şifrelerini devre dışı bırakmış olabilir. Daha fazla bilgi için lütfen {$support_url} adresini ziyaret edin. application_password_blocked_by_multiple_plugins = {$url} adresine giriş yapılamıyor – Uygulama Şifrelerini devre dışı bırakmış olabilecek birden fazla eklenti yüklü. Lütfen bunları devre dışı bırakın ve tekrar deneyin.