Skip to content

Never use an http REST API endpoint for https self-hosted sites - #25914

Merged
crazytonyli merged 2 commits into
release/27.2from
fix/self-hosted-rest-api-http-downgrade
Aug 20, 2026
Merged

Never use an http REST API endpoint for https self-hosted sites#25914
crazytonyli merged 2 commits into
release/27.2from
fix/self-hosted-rest-api-http-downgrade

Conversation

@jkmassel

Copy link
Copy Markdown
Contributor

Follow-up to #25869. That PR closed the XML-RPC credential-over-http downgrade for https self-hosted sites; the same downgraded xmlrpc value also feeds the REST API base, which it left unaddressed.

Note

Like #25869, this only bites a site whose xmlrpc an older app version persisted as http:// for an https:// site (GHSA-qxpr-7v78-mh5g) — an uncommon state, but the credential exposure is the same class.

Summary

  • WordPressOrgRestApi(blog:) built its base URL from Blog.url(withPath: "wp-json/"), which rewrites the raw xmlrpc string and copies its scheme verbatim.
  • For a downgraded site that base was http://…/wp-json/, and the application password rides every request as a preemptive Authorization: Basic … header — so it could be sent in plaintext. ATS does not block it (NSAllowsArbitraryLoads is set in both apps' Info.plist).
  • The fix prefers restApiRootURL — the REST root observed during discovery — over the xmlrpc-derived URL.

Root Cause

Blog.url(withPath:) derives sibling URLs by regex-replacing xmlrpc.php in the stored xmlrpc string, so it inherits whatever scheme was persisted. apiBase(blog:) used it directly and ignored restApiRootURL, even though restApiRootURL holds the https root that discovery actually observed (and EditorConfiguration already prefers it).

Fix

  • Add Blog.selfHostedRestApiRootURL: prefer restApiRootURL, fall back to the xmlrpc-derived wp-json/ URL only when no discovered root was persisted.
  • Route apiBase(blog:) through it.

restApiRootURL is 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

  • Legacy account-password sites (username/password, no application token) never ran REST discovery, so restApiRootURL is nil and the fallback still yields the xmlrpc-derived URL. Their password rides the cookie-nonce login POST to loginURL (which already prefers the https login_url option); the residual is the data requests' nonce/cookies. Fully closing that needs transport-layer enforcement — see below.
  • Server-issued https→http redirects at request time are out of scope here — the same deferral Never use an http XML-RPC endpoint for https sites #25869 documents. The durable fix for both this and the XML-RPC path is refusing to put credentials on http in the client itself.
  • Non-pretty-permalink sites: a discovered restApiRootURL can be …/?rest_route=/ rather than …/wp-json/, which would trip assert(apiURL.lastPathComponent == "wp-json") in WordPressOrgRestApi.init(selfHostedSiteWPJSONURL:) (debug only). Pretty permalinks are near-universal so the common case is unaffected, but the assert may want relaxing.

Test Plan

  • Unit tests for 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.
  • Manual: add an https self-hosted site with an application password, confirm REST traffic (plugins, block editor settings) goes over https.
  • Regression: a normal https site and a genuinely-http site both continue to work.

Related

@dangermattic

dangermattic commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator
2 Messages
📖 This PR contains changes to RELEASE-NOTES.txt.
Note that these changes won't affect the final version of the release notes as this version is in code freeze.
Please, get in touch with a release manager if you want to update the final release notes.
📖 This PR is still a Draft: some checks will be skipped.

Generated by 🚫 Danger

@jkmassel jkmassel self-assigned this Aug 19, 2026
@jkmassel jkmassel added this to the 27.3 milestone Aug 19, 2026
…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.
@wpmobilebot

wpmobilebot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor
App Icon📲 You can test the changes from this Pull Request in WordPress by scanning the QR code below to install the corresponding build.
App NameWordPress
ConfigurationRelease-Alpha
Build Number33841
VersionPR #25914
Bundle IDorg.wordpress.alpha
Commit95549dc
Installation URL1vlksmfkrna58
Automatticians: You can use our internal self-serve MC tool to give yourself access to those builds if needed.

@jkmassel
jkmassel force-pushed the fix/self-hosted-rest-api-http-downgrade branch from ee22828 to 95549dc Compare August 19, 2026 21:53
@jkmassel jkmassel modified the milestones: 27.3, 27.2 ❄️ Aug 19, 2026
@jkmassel
jkmassel changed the base branch from trunk to release/27.2 August 19, 2026 21:53
@wpmobilebot

wpmobilebot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor
App Icon📲 You can test the changes from this Pull Request in Jetpack by scanning the QR code below to install the corresponding build.
App NameJetpack
ConfigurationRelease-Alpha
Build Number33841
VersionPR #25914
Bundle IDcom.jetpack.alpha
Commit95549dc
Installation URL365kr6pap4bso
Automatticians: You can use our internal self-serve MC tool to give yourself access to those builds if needed.

@jkmassel
jkmassel marked this pull request as ready for review August 19, 2026 23:01
@jkmassel
jkmassel requested a review from crazytonyli August 19, 2026 23:01
/// 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.

@crazytonyli
crazytonyli merged commit 69d8aa7 into release/27.2 Aug 20, 2026
30 checks passed
@crazytonyli
crazytonyli deleted the fix/self-hosted-rest-api-http-downgrade branch August 20, 2026 00:09
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants