Skip to content

Commit bc35787

Browse files
committed
Stop full-row site writes from clobbering app-password credentials
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.
1 parent 7c30fea commit bc35787

4 files changed

Lines changed: 184 additions & 22 deletions

File tree

libs/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/SiteSqlUtils.kt

Lines changed: 74 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -221,12 +221,20 @@ class SiteSqlUtils
221221
AppLog.d(DB, "Updating site: " + finalSiteModel.url)
222222
val oldId = siteResult[0].id
223223
try {
224-
// WP_API_REST_URL is healed/discovered locally (see updateWpApiRestUrl) and must not be
225-
// overwritten by stale full-row writes, so it is excluded from the generic update mapper.
224+
// WP_API_REST_URL and the application-password credential columns are written out of band by
225+
// dedicated single-column writers (updateWpApiRestUrl / updateApplicationPasswordCredentials),
226+
// so they're excluded from the generic mapper to keep stale full-row writes from clobbering them.
226227
WellSql.update(SiteModel::class.java).whereId(oldId)
227228
.put(
228229
finalSiteModel,
229-
UpdateAllExceptId(SiteModel::class.java, SiteModelTable.WP_API_REST_URL)
230+
UpdateAllExceptId(
231+
SiteModel::class.java,
232+
SiteModelTable.WP_API_REST_URL,
233+
SiteModelTable.API_REST_USERNAME,
234+
SiteModelTable.API_REST_PASSWORD,
235+
SiteModelTable.API_REST_USERNAME_IV,
236+
SiteModelTable.API_REST_PASSWORD_IV
237+
)
230238
).execute()
231239
} catch (e: SQLiteConstraintException) {
232240
AppLog.e(
@@ -288,21 +296,76 @@ class SiteSqlUtils
288296
fun clearWpApiRestUrl(localId: Int): Int = updateWpApiRestUrl(localId, "")
289297

290298
/**
291-
* Updates [SiteModel.wpApiRestUrl] for an application-password (ORIGIN_WPAPI) site identified by its URL.
292-
* Such sites are fetched as fresh models with no local id and no remote site id
293-
* (see SiteWPAPIRestClient.fetchWPAPISite), so the local-id-keyed [updateWpApiRestUrl] can't target them.
294-
* Scoped to ORIGIN_WPAPI so it can't touch a WP.com/Jetpack row that happens to share the same URL.
299+
* Targeted writer for the application-password credential columns: the encrypted username and password
300+
* and their IVs. The four are written as a set — the IVs are required to decrypt the ciphertext, so
301+
* persisting the values without their IVs would break reads. This is the sole writer of these columns on
302+
* an existing row: the generic full-row update path excludes them so a credential-less inbound SiteModel
303+
* (e.g. a /me/sites sync model) can't zero them out.
304+
*/
305+
fun updateApplicationPasswordCredentials(localId: Int, usernamePlain: String, passwordPlain: String): Int {
306+
val username = encryptionUtils.encrypt(usernamePlain)
307+
val password = encryptionUtils.encrypt(passwordPlain)
308+
return WellSql.update(SiteModel::class.java)
309+
.whereId(localId)
310+
.put(localId, { _ ->
311+
val cv = ContentValues()
312+
cv.put(SiteModelTable.API_REST_USERNAME, username.first)
313+
cv.put(SiteModelTable.API_REST_USERNAME_IV, username.second)
314+
cv.put(SiteModelTable.API_REST_PASSWORD, password.first)
315+
cv.put(SiteModelTable.API_REST_PASSWORD_IV, password.second)
316+
cv
317+
}).execute()
318+
}
319+
320+
/**
321+
* Clears the application-password credential columns (encrypted username/password and their IVs) for the
322+
* given local id. Companion to [updateApplicationPasswordCredentials] for the sign-out / revoke paths,
323+
* since the generic update path no longer touches these columns.
324+
*/
325+
fun clearApplicationPasswordCredentials(localId: Int): Int {
326+
return WellSql.update(SiteModel::class.java)
327+
.whereId(localId)
328+
.put(localId, { _ ->
329+
val cv = ContentValues()
330+
cv.put(SiteModelTable.API_REST_USERNAME, "")
331+
cv.put(SiteModelTable.API_REST_USERNAME_IV, "")
332+
cv.put(SiteModelTable.API_REST_PASSWORD, "")
333+
cv.put(SiteModelTable.API_REST_PASSWORD_IV, "")
334+
cv
335+
}).execute()
336+
}
337+
338+
/**
339+
* URL-keyed variant of [updateWpApiRestUrl] for application-password (ORIGIN_WPAPI) sites, which are
340+
* fetched as fresh models with no local id (see SiteWPAPIRestClient.fetchWPAPISite). Scoped to
341+
* ORIGIN_WPAPI so it can't touch a WP.com/Jetpack row that happens to share the same URL.
295342
*/
296343
fun updateWpApiRestUrlForWPAPISite(siteUrl: String, wpApiRestUrl: String): Int {
297-
val site = WellSql.select(SiteModel::class.java)
344+
val localId = wpApiSiteLocalIdByUrl(siteUrl) ?: return 0
345+
return updateWpApiRestUrl(localId, wpApiRestUrl)
346+
}
347+
348+
/**
349+
* URL-keyed variant of [updateApplicationPasswordCredentials] for ORIGIN_WPAPI sites fetched without a
350+
* local id. See [updateWpApiRestUrlForWPAPISite].
351+
*/
352+
fun updateApplicationPasswordCredentialsForWPAPISite(
353+
siteUrl: String,
354+
usernamePlain: String,
355+
passwordPlain: String
356+
): Int {
357+
val localId = wpApiSiteLocalIdByUrl(siteUrl) ?: return 0
358+
return updateApplicationPasswordCredentials(localId, usernamePlain, passwordPlain)
359+
}
360+
361+
private fun wpApiSiteLocalIdByUrl(siteUrl: String): Int? =
362+
WellSql.select(SiteModel::class.java)
298363
.where().beginGroup()
299364
.equals(SiteModelTable.URL, siteUrl)
300365
.equals(SiteModelTable.ORIGIN, SiteModel.ORIGIN_WPAPI)
301366
.endGroup().endWhere()
302367
.asModel
303-
.firstOrNull() ?: return 0
304-
return updateWpApiRestUrl(site.id, wpApiRestUrl)
305-
}
368+
.firstOrNull()?.id
306369

307370
val wPComSites: SelectQuery<SiteModel>
308371
get() = WellSql.select(SiteModel::class.java)

libs/fluxc/src/main/java/org/wordpress/android/fluxc/store/SiteStore.kt

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1616,10 +1616,19 @@ open class SiteStore @Inject constructor(
16161616
siteModel.wpApiRestUrl = payload.apiRootUrl
16171617
}
16181618
val result = updateSite(siteModel)
1619-
// updateSite's full-row write skips WP_API_REST_URL, and this fresh site has no local id
1620-
// (it's matched by URL), so persist the discovered URL via the URL-keyed writer. See SiteSqlUtils.
1621-
if (!siteModel.isError && payload.apiRootUrl.isNotEmpty()) {
1622-
siteSqlUtils.updateWpApiRestUrlForWPAPISite(siteModel.url, payload.apiRootUrl)
1619+
// updateSite's full-row write skips WP_API_REST_URL and the credential columns, and this fresh
1620+
// site has no local id (it's matched by URL), so persist them via the URL-keyed writers.
1621+
if (!siteModel.isError) {
1622+
val username = siteModel.apiRestUsernamePlain
1623+
val password = siteModel.apiRestPasswordPlain
1624+
if (!username.isNullOrEmpty() && !password.isNullOrEmpty()) {
1625+
siteSqlUtils.updateApplicationPasswordCredentialsForWPAPISite(
1626+
siteModel.url, username, password
1627+
)
1628+
}
1629+
if (payload.apiRootUrl.isNotEmpty()) {
1630+
siteSqlUtils.updateWpApiRestUrlForWPAPISite(siteModel.url, payload.apiRootUrl)
1631+
}
16231632
}
16241633
result
16251634
} catch (e: Exception) {
@@ -1702,9 +1711,14 @@ open class SiteStore @Inject constructor(
17021711
siteFromDB
17031712
}
17041713
val rowsAffected = siteSqlUtils.insertOrUpdateSite(siteToStore)
1705-
// The generic update path no longer writes WP_API_REST_URL (see SiteSqlUtils), so when the
1706-
// application-password flow discovered a REST URL for an existing site, persist it explicitly.
1714+
// The credential columns and WP_API_REST_URL are excluded from the generic update path (see
1715+
// SiteSqlUtils), so persist them through their targeted writers for an existing site.
17071716
if (siteFromDB != null) {
1717+
val username = siteModel.apiRestUsernamePlain
1718+
val password = siteModel.apiRestPasswordPlain
1719+
if (!username.isNullOrEmpty() && !password.isNullOrEmpty()) {
1720+
siteSqlUtils.updateApplicationPasswordCredentials(siteFromDB.id, username, password)
1721+
}
17081722
siteModel.wpApiRestUrl?.takeIf { it.isNotEmpty() }?.let {
17091723
siteSqlUtils.updateWpApiRestUrl(siteFromDB.id, it)
17101724
}
@@ -1741,8 +1755,9 @@ open class SiteStore @Inject constructor(
17411755
wpApiRestUrl = ""
17421756
}
17431757
val rowsAffected = siteSqlUtils.insertOrUpdateSite(siteFromDB)
1744-
// The generic update path no longer writes WP_API_REST_URL (see SiteSqlUtils), so clear the
1745-
// stored REST URL explicitly now that the application password backing it is gone.
1758+
// The credential columns and WP_API_REST_URL are excluded from the generic update path (see
1759+
// SiteSqlUtils), so clear them explicitly now that the application password is gone.
1760+
siteSqlUtils.clearApplicationPasswordCredentials(siteFromDB.id)
17461761
siteSqlUtils.clearWpApiRestUrl(siteFromDB.id)
17471762
OnSiteChanged(rowsAffected)
17481763
}
@@ -2489,7 +2504,11 @@ open class SiteStore @Inject constructor(
24892504
apiRestUsernameIV = ""
24902505
apiRestPasswordIV = ""
24912506
}
2492-
OnSiteChanged(siteSqlUtils.insertOrUpdateSite(siteFromDB))
2507+
val rowsAffected = siteSqlUtils.insertOrUpdateSite(siteFromDB)
2508+
// The credential columns are excluded from the generic update path (see SiteSqlUtils); clear them
2509+
// explicitly. wpApiRestUrl is intentionally preserved here (see KDoc) — rotation reuses it.
2510+
siteSqlUtils.clearApplicationPasswordCredentials(siteFromDB.id)
2511+
OnSiteChanged(rowsAffected)
24932512
} catch (e: DuplicateSiteException) {
24942513
OnSiteChanged(SiteError(DUPLICATE_SITE))
24952514
}

libs/fluxc/src/test/java/org/wordpress/android/fluxc/persistence/SiteSqlUtilsTest.kt

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,4 +140,58 @@ class SiteSqlUtilsTest {
140140
assertThat(rows).isEqualTo(0)
141141
assertThat(siteSqlUtils.getSites().single().wpApiRestUrl).isNull()
142142
}
143+
144+
@Test
145+
fun `insertOrUpdateSite update does not clobber credential columns from a stale model`() {
146+
WellSql.insert(SiteModel().apply {
147+
siteId = 42
148+
url = "https://example.test"
149+
apiRestUsernameEncrypted = "enc_user"
150+
apiRestUsernameIV = "iv_user"
151+
apiRestPasswordEncrypted = "enc_pass"
152+
apiRestPasswordIV = "iv_pass"
153+
}).execute()
154+
val localId = storedSite().id
155+
156+
// A credential-less inbound model (e.g. a /me/sites sync) must not zero the credential columns.
157+
val stale = SiteModel().apply {
158+
id = localId
159+
siteId = 42
160+
url = "https://example.test"
161+
name = "Updated name"
162+
}
163+
siteSqlUtils.insertOrUpdateSite(stale)
164+
165+
val stored = storedSite()
166+
assertThat(stored.apiRestUsernameEncrypted).isEqualTo("enc_user")
167+
assertThat(stored.apiRestUsernameIV).isEqualTo("iv_user")
168+
assertThat(stored.apiRestPasswordEncrypted).isEqualTo("enc_pass")
169+
assertThat(stored.apiRestPasswordIV).isEqualTo("iv_pass")
170+
assertThat(stored.name).isEqualTo("Updated name") // other columns still update
171+
}
172+
173+
@Test
174+
fun `clearApplicationPasswordCredentials empties the credential columns`() {
175+
WellSql.insert(SiteModel().apply {
176+
url = "https://example.test"
177+
apiRestUsernameEncrypted = "enc_user"
178+
apiRestUsernameIV = "iv_user"
179+
apiRestPasswordEncrypted = "enc_pass"
180+
apiRestPasswordIV = "iv_pass"
181+
}).execute()
182+
val localId = storedSite().id
183+
184+
val rows = siteSqlUtils.clearApplicationPasswordCredentials(localId)
185+
186+
assertThat(rows).isEqualTo(1)
187+
val stored = storedSite()
188+
assertThat(stored.apiRestUsernameEncrypted).isEmpty()
189+
assertThat(stored.apiRestUsernameIV).isEmpty()
190+
assertThat(stored.apiRestPasswordEncrypted).isEmpty()
191+
assertThat(stored.apiRestPasswordIV).isEmpty()
192+
}
193+
194+
// Raw read that bypasses SiteSqlUtils' decryptAPIRestCredentials, so tests can assert on the stored
195+
// ciphertext columns directly without invoking the AndroidKeyStore-backed EncryptionUtils.
196+
private fun storedSite(): SiteModel = WellSql.select(SiteModel::class.java).asModel.single()
143197
}

libs/fluxc/src/test/java/org/wordpress/android/fluxc/store/SiteStoreTest.kt

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import org.mockito.junit.MockitoJUnitRunner
1111
import org.mockito.kotlin.any
1212
import org.mockito.kotlin.argWhere
1313
import org.mockito.kotlin.inOrder
14+
import org.mockito.kotlin.mock
1415
import org.mockito.kotlin.never
1516
import org.mockito.kotlin.verify
1617
import org.mockito.kotlin.verifyNoInteractions
@@ -25,6 +26,7 @@ import org.wordpress.android.fluxc.model.jetpacksocial.JetpackSocialMapper
2526
import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError
2627
import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR
2728
import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.PARSE_ERROR
29+
import org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords.ApplicationPasswordsManager
2830
import org.wordpress.android.fluxc.network.rest.wpapi.site.SiteWPAPIRestClient
2931
import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError
3032
import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response
@@ -62,6 +64,7 @@ import org.wordpress.android.fluxc.store.SiteStore.SiteFilter.WPCOM
6264
import org.wordpress.android.fluxc.store.SiteStore.SiteVisibility.PUBLIC
6365
import org.wordpress.android.fluxc.test
6466
import org.wordpress.android.fluxc.tools.initCoroutineEngine
67+
import javax.inject.Provider
6568
import kotlin.test.assertEquals
6669

6770
@RunWith(MockitoJUnitRunner::class)
@@ -582,10 +585,15 @@ class SiteStoreTest {
582585
"https://example.com",
583586
"https://example.com/wp-json/"
584587
)
588+
verify(siteSqlUtils).updateApplicationPasswordCredentialsForWPAPISite(
589+
"https://example.com",
590+
"appUser",
591+
"appPass"
592+
)
585593
}
586594

587595
@Test
588-
fun `updateApplicationPassword persists discovered wpApiRestUrl via targeted writer`() {
596+
fun `updateApplicationPassword persists credentials and wpApiRestUrl via targeted writers`() {
589597
val existing = SiteModel().apply {
590598
id = 3
591599
url = "https://selfhosted.test"
@@ -601,11 +609,12 @@ class SiteStoreTest {
601609

602610
siteStore.onAction(SiteActionBuilder.newUpdateApplicationPasswordAction(incoming))
603611

612+
verify(siteSqlUtils).updateApplicationPasswordCredentials(3, "user", "pass")
604613
verify(siteSqlUtils).updateWpApiRestUrl(3, "https://selfhosted.test/wp-json/")
605614
}
606615

607616
@Test
608-
fun `removeApplicationPassword clears wpApiRestUrl via targeted writer`() {
617+
fun `removeApplicationPassword clears credentials and wpApiRestUrl via targeted writers`() {
609618
val existing = SiteModel().apply {
610619
id = 4
611620
url = "https://selfhosted.test"
@@ -618,9 +627,26 @@ class SiteStoreTest {
618627
SiteActionBuilder.newRemoveApplicationPasswordAction(SiteModel().apply { id = 4 })
619628
)
620629

630+
verify(siteSqlUtils).clearApplicationPasswordCredentials(4)
621631
verify(siteSqlUtils).clearWpApiRestUrl(4)
622632
}
623633

634+
@Test
635+
fun `clearApplicationPasswordColumns clears credentials but preserves wpApiRestUrl`() {
636+
val existing = SiteModel().apply {
637+
id = 8
638+
url = "https://selfhosted.test"
639+
}
640+
whenever(siteSqlUtils.getSitesWithLocalId(8)).thenReturn(listOf(existing))
641+
whenever(siteSqlUtils.insertOrUpdateSite(any())).thenReturn(1)
642+
siteStore.applicationPasswordsManagerProvider = Provider { mock<ApplicationPasswordsManager>() }
643+
644+
siteStore.deleteStoredApplicationPasswordCredentials(SiteModel().apply { id = 8 })
645+
646+
verify(siteSqlUtils).clearApplicationPasswordCredentials(8)
647+
verify(siteSqlUtils, never()).clearWpApiRestUrl(8)
648+
}
649+
624650
@Test
625651
fun `fetchPostFormats returns empty list for WPAPI site without crashing`() =
626652
test {

0 commit comments

Comments
 (0)