Never use an http REST API endpoint for https self-hosted sites - #25914
Merged
crazytonyli merged 2 commits intoAug 20, 2026
Conversation
Collaborator
Generated by 🚫 Danger |
…elf-hosted sites `WordPressOrgRestApi(blog:)` built its base URL from `Blog.url(withPath:)`, which rewrites the raw `xmlrpc` string. An older app version could persist an http `xmlrpc` endpoint for an https site (GHSA-qxpr-7v78-mh5g), so the self-hosted REST client — and the application password it carries as a Basic auth header — could be built on http and sent in plaintext. Prefer `restApiRootURL`, the root observed during REST discovery (https for an https site, and always written alongside the application token), falling back to the xmlrpc-derived `wp-json/` URL only when no discovered root was persisted.
Contributor
|
| App Name | WordPress | |
| Configuration | Release-Alpha | |
| Build Number | 33841 | |
| Version | PR #25914 | |
| Bundle ID | org.wordpress.alpha | |
| Commit | 95549dc | |
| Installation URL | 1vlksmfkrna58 |
jkmassel
force-pushed
the
fix/self-hosted-rest-api-http-downgrade
branch
from
August 19, 2026 21:53
ee22828 to
95549dc
Compare
Contributor
|
| App Name | Jetpack | |
| Configuration | Release-Alpha | |
| Build Number | 33841 | |
| Version | PR #25914 | |
| Bundle ID | com.jetpack.alpha | |
| Commit | 95549dc | |
| Installation URL | 365kr6pap4bso |
crazytonyli
reviewed
Aug 19, 2026
| /// 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) { |
Contributor
There was a problem hiding this comment.
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.
crazytonyli
approved these changes
Aug 19, 2026
crazytonyli
pushed a commit
that referenced
this pull request
Aug 20, 2026
* Prefer the discovered REST API root over the xmlrpc-derived URL for self-hosted sites `WordPressOrgRestApi(blog:)` built its base URL from `Blog.url(withPath:)`, which rewrites the raw `xmlrpc` string. An older app version could persist an http `xmlrpc` endpoint for an https site (GHSA-qxpr-7v78-mh5g), so the self-hosted REST client — and the application password it carries as a Basic auth header — could be built on http and sent in plaintext. Prefer `restApiRootURL`, the root observed during REST discovery (https for an https site, and always written alongside the application token), falling back to the xmlrpc-derived `wp-json/` URL only when no discovered root was persisted. * Add a release note
pull Bot
pushed a commit
to kliu/WordPress-iOS
that referenced
this pull request
Aug 20, 2026
* Rediscover the REST API root when its host does not match the site (wordpress-mobile#25903) * Rediscover the REST API root when its host does not match the site A blog's stored restApiRootURL can go stale: WP.com Simple sites advertise a public-api.wordpress.com rest_route proxy root, which breaks direct wp/v2 requests once the site migrates to Atomic and an application password starts being used, and a domain change leaves the root pointing at the old host. Because the stored value was never re-validated, features on the core REST API path (such as tags) failed with rest_no_route while WP.com v1.1 features kept working. Treat a host mismatch between the stored API root and the site URL as stale and run API discovery again, persisting the rediscovered root. When rediscovery fails, keep using the stored root so sites with unreachable or disabled discovery are no worse off than before. Roots on the site's own host are used as is, with no extra network traffic. * Propagate cancellation before falling back to the stored REST API root A cancelled API discovery surfaces as a discovery failure rather than a CancellationError, so the stored-root fallback could let a cancelled operation keep running. Check for task cancellation explicitly before applying the fallback. * Compare API root and site hosts using URL values The stale-root check parsed both the stored REST API root and the site URL strings again just to read their hosts. Fetch the site URL as a URL, reuse the already-parsed root, and compare their hosts directly, dropping the string-parsing helper. * Document the expected call frequency of createPasswordIfNeeded * Add blog properties to some events (wordpress-mobile#25908) * Attach the current site to the notifications_accessed event The notifications list spans all sites, so no single site is truly scoped to the event. Attach the currently visible (or last used) site so the account-level event carries a blog_id, consistent with other tab-access events. * Attach the site to Jetpack Stats analytics events The Stats analytics bridge (WPAnalyticsStatsTracker) forwarded every JetpackStats event without a blog_id. Pass the viewed site's ID into the tracker and stamp it on each event, so site-scoped Stats events such as jetpack_stats_card_shown and jetpack_stats_main_screen_shown carry a blog_id. * Attach the site to the editor_settings_fetched event * Attach the site to the free_to_paid_plan_dashboard_card_shown event * Attach the site to the blaze_entry_point_displayed event * Attach the site to the my_site_dashboard_shown event * Attach the site to the blogging_prompts_my_site_card_viewed event trackCardViewed is generic across dashboard cards, so this also attaches the site to jetpack_install_full_plugin_card_viewed. * Track notifications_accessed with the currently visible site Switch from currentOrLastBlog() to currentlyVisibleBlog() so the event carries a blog_id only when a site is actually on screen. When none is, track it without a site rather than stamping a stale last-used one. * Remove unused trackBlazeEntryPointDisplayed ObjC bridge The method has no remaining callers. * Attach site_type to Jetpack Stats analytics events Pass the site's BlogAnalyticsProperties snapshot to the Stats bridge instead of a bare site ID, and track through the canonical blogProperties path so every Stats event carries site_type alongside blog_id, consistent with the other site-scoped events. * Never use an http XML-RPC endpoint for https sites (wordpress-mobile#25869) * Never fall back to plaintext XML-RPC endpoints for https sites When the entered site address is https, WordPressOrgXMLRPCValidator no longer probes an http variant of the same host, and any endpoint that discovery resolves to (via redirects or RSD links) is rejected unless it is also https. Previously the username and application password were sent to the http endpoint whenever the https xmlrpc.php probe failed, and the plaintext endpoint was persisted (GHSA-qxpr-7v78-mh5g). * Use an https XML-RPC endpoint for https sites at request time Older app versions could silently downgrade a site's discovered XML-RPC endpoint to http and persist it, so later XML-RPC traffic and the credentials it carries crossed plaintext (GHSA-qxpr-7v78-mh5g). Blog.xmlrpcURL returns the https-upgraded endpoint for an https site, and every credential bearing XML-RPC client is now built from it (Blog.xmlrpcApi, the Zendesk profile fetch, and the site settings credential check) instead of the raw stored value. The persisted xmlrpc and the Keychain keyed by it are left unchanged, so credential lookups still resolve; only the request endpoint is upgraded, at the point the client is constructed. This closes the downgrade regardless of launch timing or store restoration, with no migration pass. * Add a release note --------- Co-authored-by: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> * Never use an http REST API endpoint for https self-hosted sites (wordpress-mobile#25914) * Prefer the discovered REST API root over the xmlrpc-derived URL for self-hosted sites `WordPressOrgRestApi(blog:)` built its base URL from `Blog.url(withPath:)`, which rewrites the raw `xmlrpc` string. An older app version could persist an http `xmlrpc` endpoint for an https site (GHSA-qxpr-7v78-mh5g), so the self-hosted REST client — and the application password it carries as a Basic auth header — could be built on http and sent in plaintext. Prefer `restApiRootURL`, the root observed during REST discovery (https for an https site, and always written alongside the application token), falling back to the xmlrpc-derived `wp-json/` URL only when no discovered root was persisted. * Add a release note * Update strings for localization * Update app translations – `Localizable.strings` * Update WordPress metadata translations * Update Jetpack metadata translations * Bump version number --------- Co-authored-by: Tony Li <tony.li@automattic.com> Co-authored-by: Jeremy Massel <1123407+jkmassel@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Follow-up to #25869. That PR closed the XML-RPC credential-over-http downgrade for https self-hosted sites; the same downgraded
xmlrpcvalue also feeds the REST API base, which it left unaddressed.Note
Like #25869, this only bites a site whose
xmlrpcan older app version persisted ashttp://for anhttps://site (GHSA-qxpr-7v78-mh5g) — an uncommon state, but the credential exposure is the same class.Summary
WordPressOrgRestApi(blog:)built its base URL fromBlog.url(withPath: "wp-json/"), which rewrites the rawxmlrpcstring and copies its scheme verbatim.http://…/wp-json/, and the application password rides every request as a preemptiveAuthorization: Basic …header — so it could be sent in plaintext. ATS does not block it (NSAllowsArbitraryLoadsis set in both apps'Info.plist).restApiRootURL— the REST root observed during discovery — over the xmlrpc-derived URL.Root Cause
Blog.url(withPath:)derives sibling URLs by regex-replacingxmlrpc.phpin the storedxmlrpcstring, so it inherits whatever scheme was persisted.apiBase(blog:)used it directly and ignoredrestApiRootURL, even thoughrestApiRootURLholds the https root that discovery actually observed (andEditorConfigurationalready prefers it).Fix
Blog.selfHostedRestApiRootURL: preferrestApiRootURL, fall back to thexmlrpc-derivedwp-json/URL only when no discovered root was persisted.apiBase(blog:)through it.restApiRootURLis written together with the application token (ApplicationPasswordRepository.assign,Blog.createRestApiBlog), so it is always present when the token is. That makes this a discovery-backed choice, not a scheme-rewriting guess: an intentionally-http site stays http (its discovered root is http); an https site uses the https root discovery saw.Scope — what this does not cover
restApiRootURLis nil and the fallback still yields thexmlrpc-derived URL. Their password rides the cookie-nonce login POST tologinURL(which already prefers the httpslogin_urloption); the residual is the data requests' nonce/cookies. Fully closing that needs transport-layer enforcement — see below.restApiRootURLcan be…/?rest_route=/rather than…/wp-json/, which would tripassert(apiURL.lastPathComponent == "wp-json")inWordPressOrgRestApi.init(selfHostedSiteWPJSONURL:)(debug only). Pretty permalinks are near-universal so the common case is unaffected, but the assert may want relaxing.Test Plan
selfHostedRestApiRootURL(added; run in CI): prefers the https discovered root over a downgraded http xmlrpc endpoint; falls back to the xmlrpc-derived root when none was discovered; nil when neither is available.Related
GHSA-qxpr-7v78-mh5g