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
28 changes: 28 additions & 0 deletions Modules/Sources/WordPressData/Swift/Blog+SelfHostedRestApi.swift
Original file line number Diff line number Diff line change
@@ -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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking if restApiRootURL should do the same scheme check in xmlrpcURL. But it's probably fine to leave it. The value comes from the api discovery process, and I plan to warn about using http urls in #25870.

return url
}
return url(withPath: "wp-json/").flatMap { URL(string: $0) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
44 changes: 44 additions & 0 deletions Modules/Tests/WordPressDataTests/BlogSelfHostedRestApiTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down