Skip to content

Make wpApiRestUrl, xmlRpcUrl, and app-password creds single-writer#22947

Draft
jkmassel wants to merge 11 commits into
trunkfrom
jkmassel/sitesqlutils-partial-updates
Draft

Make wpApiRestUrl, xmlRpcUrl, and app-password creds single-writer#22947
jkmassel wants to merge 11 commits into
trunkfrom
jkmassel/sitesqlutils-partial-updates

Conversation

@jkmassel

@jkmassel jkmassel commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes #22905. Several SiteModel columns are discovered or healed out of band — not
carried by the general site-sync responses — yet every full-row insertOrUpdateSite UPDATE
wrote them. When the in-memory SiteModel came from a source that doesn't carry a field
(/me/sites, /sites/<id>, the RN bridge, cookie-nonce auth), the full-row write stamped
its stale/null over a good value. On Atomic sites the recovered wpApiRestUrl was wiped on
every app foreground; the same mechanism zeroes the application-password credential columns.
xmlRpcUrl is the same shape of bug but milder — the WP.com sync reliably carries it, so only a
partial writer that doesn't (the WPAPI fetch) can null it. See #3 below.

Root cause

SiteSqlUtils.insertOrUpdateSite updates via WellSql.update().put(model, UpdateAllExceptId(...)),
which writes every column except _id. Per-handler preservation existed for some columns
but was gated on encrypted AP creds being present — which Atomic sites don't have — so it
didn't fire for them, and it was TOCTOU-fragile regardless.

Fix

Two mechanisms, matched to how each column is sourced:

  • Excluded from the generic mapperWP_API_REST_URL and the app-password credential columns.
    These are never carried by a site-sync response, so the full-row UPDATE skips them entirely (via a new
    UpdateAllExceptId skip-list) and a targeted single-column writer is their sole writer on an
    existing row. INSERT is untouched. Every caller that legitimately set one is rerouted to the targeted
    writer; redundant writes are removed.
  • Preserved on absenceXMLRPC_URL. The WP.com sync does carry it, so the full-row UPDATE still
    writes it (that's how a changed endpoint lands); it just keeps the stored value when the inbound model
    carries none, so a partial writer can't null it.

Column-by-column:

1. WP_API_REST_URL

  • updateWpApiRestUrl (the heal writer from Populate missing wpApiRestUrl during editor preload #22903) becomes the sole writer; clearWpApiRestUrl
    for sign-out.
  • Rerouted the discover/persist callers: updateApplicationPassword, CookieNonceAuthenticator,
    ReactNativeStore, and fetchSiteWPAPIFromApplicationPassword (URL-keyed, since the freshly
    fetched site has no local id).

2. Application-password credentials

  • API_REST_USERNAME, API_REST_PASSWORD and their two IV columns, excluded as a set — the
    IVs are required to decrypt the ciphertext, so persisting the values without them breaks reads.
  • New updateApplicationPasswordCredentials (encrypts) / clearApplicationPasswordCredentials,
    wired into updateApplicationPassword, removeApplicationPassword (full sign-out — also clears
    wpApiRestUrl), clearApplicationPasswordColumns (rotation — intentionally preserves
    wpApiRestUrl), and the WPAPI app-password fetch.
  • createOrUpdateSites persists creds via the targeted writer too: insertOrUpdateSite gained an
    insertOrUpdateSiteReturningId variant so the handler can target the exact written row. This
    fixes app-password login of an existing XML-RPC site, where the exclusion would otherwise
    drop the creds.

3. XMLRPC_URL — preserved, not excluded

Unlike the other two, the WP.com REST sync reliably returns meta.links.xmlrpc (verified against
/me/sites), so excluding the column would silently drop a changed endpoint (e.g. a domain migration)
on an existing row. Instead, insertOrUpdateSite preserves it on absence — copying the stored value
forward only when the inbound model has none — so the WPAPI fetch (which builds a model with a null
xmlRpcUrl) can't clobber a stored/rediscovered value.

  • updateXmlRpcUrl / SiteStore.persistXmlRpcUrl remain for the single-column rediscovery heal — one
    targeted write instead of an ~80-column full-row updateSite.
  • attemptXmlRpcRediscovery (the My Site app-password card) persists the rediscovered endpoint through
    that writer.

Cleanup enabled by the migration

  • Removed the 404 no-op wpApiRestUrl writes in CookieNonceAuthenticator / ReactNativeStore
    (and RN's now-orphaned persistSiteSafely / sitePersistanceFunction) — they reset the column
    in memory to drive rediscovery and never needed to persist.
  • Removed the moot gated credential/wpApiRestUrl copy-forward blocks in updateSite /
    createOrUpdateSites.
  • Collapsed updateApplicationPassword / removeApplicationPassword / clearApplicationPasswordColumns
    to their targeted writers — the in-memory mutations and self-insertOrUpdateSite they used to
    do persisted nothing once the columns were excluded.

Defensive hardening (unrelated to the clobber)

  • decryptAPIRestCredentials now also short-circuits when an IV column is blank (not just when the
    ciphertext is empty), so a malformed ciphertext-without-IV row reads as "no credentials" instead
    of throwing on decrypt(ciphertext, ""). Cheap, and on the same read path.

Out of scope / follow-ups

Testing instructions

Automated

  1. Run ./gradlew :libs:fluxc:testDebugUnitTest
  • Green (bar a pre-existing timezone-dependent StatsUtilsTest flake unrelated to this change).
    SiteSqlUtilsTest covers each column family — a stale full-row update preserves the protected
    column while still updating the others, and for xmlRpcUrl that a carried value still overwrites
    (the migration case) — plus the targeted writers and the blank-IV decrypt guard.
  1. Run ./gradlew :WordPress:testJetpackDebugUnitTest --tests "*ApplicationPasswordViewModelSliceTest"
  • Green — XML-RPC rediscovery now verifies the targeted persistXmlRpcUrl write.

Manual — Atomic site (the original repro)

  1. Sign in with an Atomic site (e.g. *.jurassic.ninja) and open it so wpApiRestUrl is
    discovered/healed.
  2. Background/foreground a few times (triggers FETCH_SITE/FETCH_SITES), then kill and relaunch.
  • WP_API_REST_URL survives — no longer reset to NULL across launches.

Manual — self-hosted application-password site

  1. Add an application password to a self-hosted site, then load My Site a few times.
  • Credentials persist; no false "Unable to connect to your site" banner.
  1. If the site's XML-RPC was disabled and is re-enabled, confirm the card's rediscovery persists.
  • xmlRpcUrl is repopulated and survives a relaunch.
  1. Remove the application password.
  • Credentials and wpApiRestUrl are cleared.

On Atomic / Jetpack-WPCom-REST sites a recovered wpApiRestUrl was wiped
to NULL on every app foreground: any full-row insertOrUpdateSite UPDATE
built from a partial in-memory SiteModel (FETCH_SITE/FETCH_SITES, RN,
cookie-nonce) wrote every column, clobbering the healed value.

Exclude WP_API_REST_URL from the generic UpdateAllExceptId mapper so
updateWpApiRestUrl is the sole writer on an existing row, and route every
legitimate writer through the targeted helpers.

Fixes #22905.
@dangermattic

dangermattic commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator
1 Warning
⚠️ This PR is larger than 300 lines of changes. Please consider splitting it into smaller PRs for easier and faster reviews.
1 Message
📖 This PR is still a Draft: some checks will be skipped.

Generated by 🚫 Danger

@wpmobilebot

wpmobilebot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

App Icon📲 You can test the changes from this Pull Request in WordPress Android by scanning the QR code below to install the corresponding build.

App NameWordPress Android
Build TypeDebug
Versionpr22947-67118a8
Build Number1493
Application IDorg.wordpress.android.prealpha
Commit67118a8
Installation URL1k0fb0fjt71j0
Automatticians: You can use our internal self-serve MC tool to give yourself access to those builds if needed.

@wpmobilebot

wpmobilebot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

App Icon📲 You can test the changes from this Pull Request in Jetpack Android by scanning the QR code below to install the corresponding build.

App NameJetpack Android
Build TypeDebug
Versionpr22947-67118a8
Build Number1493
Application IDcom.jetpack.android.prealpha
Commit67118a8
Installation URL4is49v3nkekpg
Automatticians: You can use our internal self-serve MC tool to give yourself access to those builds if needed.

@wpmobilebot

wpmobilebot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

🤖 Build Failure Analysis

This build has failures. Claude has analyzed them - check the build annotations for details.

The same full-row insertOrUpdateSite UPDATE that clobbered wpApiRestUrl
also zeroed the application-password credential columns: a credential-less
inbound SiteModel (e.g. a /me/sites sync) overwrote the encrypted
username/password and their IVs with empty values.

Exclude API_REST_USERNAME, API_REST_PASSWORD and their IV columns from the
UpdateAllExceptId mapper (as a set — the IVs are required to decrypt), and
route the credential writers through targeted single-column writers.

Extends the #22905 fix to the credential columns.
@jkmassel jkmassel changed the title Stop full-row site writes from clobbering wpApiRestUrl Make wpApiRestUrl, xmlRpcUrl, and app-password creds single-writer Jun 5, 2026
eq(xmlRpcUrl), any(), any()
)
).thenReturn(SitesModel(listOf(SiteModel())))
whenever(siteStore.persistXmlRpcUrl(any(), any())).thenReturn(mock())
jkmassel added 9 commits June 9, 2026 16:13
xmlRpcUrl is set either out of band (XML-RPC rediscovery) or by the
WP.com REST sync (meta.links.xmlrpc). A full-row insertOrUpdateSite
UPDATE built from a partial SiteModel that doesn't carry it — e.g. the
WPAPI fetch, which builds a model with a null xmlRpcUrl — wrote that
null over a good value.

Preserve XMLRPC_URL on absence in the generic update path: keep the
stored value when the inbound model has none, persist it when the model
carries one (the WP.com sync, including a changed endpoint after a
domain migration). Add a targeted updateXmlRpcUrl writer for the
single-column rediscovery heal; the recovery flow that calls it lands
separately.

Extends the #22905 fix to XMLRPC_URL.
attemptXmlRpcRediscovery dispatched newUpdateSiteAction to change one
field, which (with XMLRPC_URL excluded from the full-row mapper) dropped
the value and rewrote ~80 unchanged columns. Add SiteStore.persistXmlRpcUrl
and route rediscovery through it — one targeted write, mirroring the
wpApiRestUrl heal. Drops the now-unused dispatcher from the slice.
createOrUpdateSites (the XML-RPC app-password login store path) does a
full-row write that excludes the credential columns, with no targeted
writer to follow up — so re-logging into an already-stored self-hosted
site silently dropped the credentials.

Add SiteSqlUtils.insertOrUpdateSiteReturningId, which returns the local
id of the row it wrote; insertOrUpdateSite becomes a thin rows-affected
wrapper over it (behavior unchanged). createOrUpdateSites uses the
returned id to persist credentials + wpApiRestUrl via the targeted
writers on the exact row.
- The cookie-nonce and React Native 404 handlers reset wpApiRestUrl in
  memory to force rediscovery on retry, then persisted via
  insertOrUpdateSite/persistSiteSafely — now a no-op for the excluded
  column. Drop the dead DB write (keep the in-memory reset), which also
  orphaned ReactNativeStore.persistSiteSafely and its injected persist
  function; remove those.
- updateSite / createOrUpdateSites copied credentials + wpApiRestUrl from
  the DB onto the inbound model before the write; the mapper exclusion
  already preserves those columns, so drop the moot copy (editor-prefs
  copy stays).
- Rework ReactNativeStoreWPAPITest to mock SiteSqlUtils and assert on
  updateWpApiRestUrl (the discover path persists there now).
After the single-writer migration the full-row write skips the credential and
WP_API_REST_URL columns, so the in-memory mutations and the self-insertOrUpdateSite
in updateApplicationPassword, removeApplicationPassword, and
clearApplicationPasswordColumns persisted nothing — the targeted writers do all the
work. Drop the dead code (keeping the new-site insert in updateApplicationPassword)
and remove the now-unused insertOrUpdateSite stubs from the tests.
decryptAPIRestCredentials bailed only when the ciphertext columns were empty; a row
with ciphertext but a blank IV would reach decrypt(ciphertext, "") and throw on read.
Also short-circuit when either IV is empty, treating the malformed row as having no
credentials.
updateApplicationPasswordCredentials and its URL-keyed variant were
verified only against a mocked SiteSqlUtils, so their ContentValues
mapping never ran. A .first/.second swap, a wrong column, or a dropped
IV would ship uncaught — and the new blank-IV decrypt guard would mask
it as 'no credentials' rather than surfacing it.

EncryptionUtils can't run under Robolectric (AndroidKeyStore), so add a
stubbed-EncryptionUtils SiteSqlUtils and assert the column mapping via
the raw storedSite() read, plus a decrypt round-trip and the ORIGIN_WPAPI
URL-scoping (writes the matching row, leaves a same-url non-WPAPI row
untouched).
UPDATE_APPLICATION_PASSWORD, the WPAPI app-password fetch, and
app-password XML-RPC login in createOrUpdateSites each duplicated the
read-plain / null-guard / write-credentials-then-wpApiRestUrl logic, with
subtle divergences (rowsAffected reassigned vs not; the URL write nested
under the credentials check in one path, independent in another).

Extract persistAppPasswordColumns(site, persistCredentials,
persistWpApiRestUrl); each caller passes its targeted writer pair (local
id or URL-keyed). wpApiRestUrl stays gated behind credentials on purpose:
a credential-less /me/sites model can be a WP.com simple site whose
getWpApiRestUrl() synthesizes a public-api proxy URL, and gating keeps
that synthetic value out of the DB.

removeApplicationPassword now sums both clear-writes instead of returning
only the wpApiRestUrl count.
updateApplicationPasswordCredentials and clearApplicationPasswordCredentials
hand-wrote the same four-column ContentValues, so the column set lived in
two places — a future column change has to touch both or clear leaves a
stale column. Route both through one private writeApplicationPasswordCredentialColumns.

ApplicationPasswordViewModelSliceTest: the xmlRpc-rediscovery-success
assertion was pre-satisfied by @before seeding siteTest.xmlRpcUrl to the
same value; reset it to null first so the assertion proves rediscovery
assigned it.
@jkmassel jkmassel force-pushed the jkmassel/sitesqlutils-partial-updates branch from 6885e4c to 67118a8 Compare June 9, 2026 22:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Recovered wpApiRestUrl doesn't survive across launches on Atomic sites

4 participants