diff --git a/Modules/Sources/WordPressData/Swift/Blog+SelfHostedRestApi.swift b/Modules/Sources/WordPressData/Swift/Blog+SelfHostedRestApi.swift new file mode 100644 index 000000000000..0bff4f73ba39 --- /dev/null +++ b/Modules/Sources/WordPressData/Swift/Blog+SelfHostedRestApi.swift @@ -0,0 +1,28 @@ +import Foundation + +extension Blog { + + /// The WordPress REST API root URL to use for self-hosted network requests. + /// + /// Prefers `restApiRootURL` — the root observed during REST API discovery, which is + /// served over https for an https site — over a URL derived from the raw `xmlrpc` + /// string. An older app version could silently downgrade and persist an http `xmlrpc` + /// endpoint for an https site (GHSA-qxpr-7v78-mh5g), and `url(withPath:)` copies that + /// scheme verbatim, so a REST client built from it would carry the application password + /// (sent as a Basic auth header) over plaintext http. + /// + /// `restApiRootURL` is written together with the application token (see + /// `ApplicationPasswordRepository.assign` and `Blog.createRestApiBlog`), so it is always + /// present when the token is. That makes this a discovery-backed choice rather than a + /// scheme-rewriting guess: an intentionally-http site is left on http (its discovered + /// root is http), and an https site uses the https root that discovery observed. + /// + /// Falls back to the `xmlrpc`-derived `wp-json/` URL only when no discovered root was + /// persisted (legacy XML-RPC sign-ins), leaving behavior unchanged for those sites. + public var selfHostedRestApiRootURL: URL? { + if let restApiRootURL, let url = URL(string: restApiRootURL) { + return url + } + return url(withPath: "wp-json/").flatMap { URL(string: $0) } + } +} diff --git a/Modules/Sources/WordPressData/Swift/WordPressOrgRestApi+WordPress.swift b/Modules/Sources/WordPressData/Swift/WordPressOrgRestApi+WordPress.swift index 8d685e197d71..a08bdb03334e 100644 --- a/Modules/Sources/WordPressData/Swift/WordPressOrgRestApi+WordPress.swift +++ b/Modules/Sources/WordPressData/Swift/WordPressOrgRestApi+WordPress.swift @@ -8,11 +8,10 @@ private func apiBase(blog: Blog) -> URL? { return nil } - guard let urlString = blog.url(withPath: "wp-json/") else { - return nil - } - - return URL(string: urlString) + // Prefer the REST root observed during discovery over one derived from the raw + // `xmlrpc` string, which an older app version could have persisted as http for an + // https site (GHSA-qxpr-7v78-mh5g). See `Blog.selfHostedRestApiRootURL`. + return blog.selfHostedRestApiRootURL } extension WordPressOrgRestApi { diff --git a/Modules/Tests/WordPressDataTests/BlogSelfHostedRestApiTests.swift b/Modules/Tests/WordPressDataTests/BlogSelfHostedRestApiTests.swift new file mode 100644 index 000000000000..afc655ee8ef5 --- /dev/null +++ b/Modules/Tests/WordPressDataTests/BlogSelfHostedRestApiTests.swift @@ -0,0 +1,44 @@ +import CoreData +import Testing +@testable import WordPressData + +@MainActor +struct BlogSelfHostedRestApiTests { + private let contextManager = ContextManager.forTesting() + + private func makeBlog(url: String?, xmlrpc: String?, restApiRootURL: String?) -> Blog { + let blog = BlogBuilder(contextManager.mainContext, dotComID: nil).build() + blog.account = nil + blog.url = url + blog.xmlrpc = xmlrpc + blog.restApiRootURL = restApiRootURL + return blog + } + + @Test func prefersDiscoveredHTTPSRootOverDowngradedXMLRPCEndpoint() { + // An older app version could persist an http xmlrpc endpoint for an https site; the + // https REST root observed during discovery must win so the application password is + // never sent over plaintext http. + let blog = makeBlog( + url: "https://example.com", + xmlrpc: "http://example.com/xmlrpc.php", + restApiRootURL: "https://example.com/wp-json/" + ) + #expect(blog.selfHostedRestApiRootURL?.absoluteString == "https://example.com/wp-json/") + } + + @Test func fallsBackToXMLRPCDerivedRootWhenNoDiscoveredRoot() { + // Legacy XML-RPC sign-ins never persisted a REST root, so behavior is unchanged. + let blog = makeBlog( + url: "https://example.com", + xmlrpc: "https://example.com/xmlrpc.php", + restApiRootURL: nil + ) + #expect(blog.selfHostedRestApiRootURL?.absoluteString == "https://example.com/wp-json/") + } + + @Test func returnsNilWhenNeitherRootIsAvailable() { + let blog = makeBlog(url: "https://example.com", xmlrpc: nil, restApiRootURL: nil) + #expect(blog.selfHostedRestApiRootURL == nil) + } +} diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 67b9ad21ae4b..fa77a30cfe75 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -4,6 +4,7 @@ 27.2 ----- +* [*] Fix an issue where an https self-hosted site's application password could be sent over insecure http when using its REST API [#25914] * [*] Custom Post Types: Make custom post types with REST API and editor support available in My Site [#25849] * [*] Stop the media picker from removing gallery images when you cancel it in the experimental editor [#25866] * [*] Stats: Fix the screen getting stuck on a loading indicator for self-hosted sites that are not connected to Jetpack [#25858]