Opened 4 days ago
Last modified 12 hours ago
#66424 new defect (bug)
Secrets API: A failed read of a secret's record reads as a missing secret
| Reported by: | ericmann | Owned by: | |
|---|---|---|---|
| Priority: | normal | Milestone: | 7.2 |
| Component: | Security | Version: | trunk |
| Severity: | normal | Keywords: | has-patch has-unit-tests |
| Cc: | Focuses: |
Description
Follow-up to [64337] (#66187). Related: #66413.
WP_Secrets_Option_Store::get() reads a record with get_option( $name, null ) (or get_site_option()). Those return the default both when the option doesn't exist and when the query fails. So a failed read, such as a killed query, a lost connection or a replica that is behind, looks like "this secret does not exist":
wp_get_secret()returnsnullinstead ofWP_Error. That breaks the three-state contract, which reservesnullfor a secret that doesn't exist. A caller that seesnullmay ask for the credential again or create a new one.wp_set_secret()treats the secret as new. It returnstrueand writes a record with no previous version, so a rotation in progress loses the value it depends on.
#66413 is the same problem for the root key.
Reproduced on trunk, single site. The first SELECT of the record fails once, through a query filter:
before: current two, previous one wp_get_secret() during a failed read: NULL wp_set_secret( three ) during a failed read: true after: current three, previous NULL
Possible fix: when get_option() returns the default, check against the database the way #66413's patch does for the root key. Return secret_store_unavailable if that read fails too. The write path already treats secret_store_unavailable from the store as fatal.
AI assistance: Claude Code (Claude Opus 5.5) for finding this and writing the reproduction. I ran it and reviewed the results.
Change History (6)
This ticket was mentioned in PR #15301 on WordPress/wordpress-develop by @dhrupo.
3 days ago
#1
- Keywords has-patch has-unit-tests added; needs-patch removed
#2
@
3 days ago
Patch and tests are in PR #15301.
One note on how the failure is detected, then four adjacent items I left out of the patch.
Detection. My first attempt used $wpdb->last_error to tell a failed read from an absent record. That misses the case this ticket is really about: when the server has gone away and check_connection() fails, wpdb::query() returns false after flush() has already cleared the error string, and without setting a new one, so last_error is empty and the secret still reads as missing. The patch takes failure from the return value of wpdb::query() instead, which is false on every failed read and a row count on every successful one. wpdb::$ready being false, and a query filter emptying the SQL, also return before flush(), so last_error can be stale in the other direction too. There is a regression test for this: it fails with the last_error check and passes with the result check.
read_stored_record() also returns false for "no row", so a row that exists while holding no value reports secret_record_malformed rather than absence. wp_options.option_value is NOT NULL, but wp_sitemeta.meta_value is nullable, so that is reachable for network-scope secrets.
Four things in the same class that the patch does not touch:
WP_Secrets_Option_Store::delete()keeps the same ambiguity: on a failed readdelete_option()returnsfalse, theget_option( $option_name, null )existence check returnsnull, so$still_existsisfalseanddelete()returnstrue. The public path is not affected once this patch lands, becauseWP_Secrets_Libsodium_Provider::delete()callsread_prior_record()first and now getssecret_store_unavailable, and thewp_secret_changed'deleted'action is guarded byis_array( $existing )so it does not fire either way. So this is the store's own contract being looser than the interface says, not a user-facing bug.list_names()has an unreachable failure guard.wpdb::get_col()initialises$new_array = array()and always returns an array, including when the query failed, so! is_array( $option_names )is never true. A failed listing returnsarray():wp_list_secrets()shows no secrets, and the Site Health check that deliberately keeps undecryptable records visible sees nothing.- A failed read leaves the record marked in the
notoptionscache and the patch does not prune it.get()stays correct, because it does the direct read on everynull, butget_option()keeps short-circuiting for the rest of the request, soupdate_option()on the next write routes throughadd_option()instead of the update path and skips theupdate_option_*andupdated_optionactions. The patch on #66413 prunes its own entry; if both land as they are, core ends up with two near-identical helpers for that, which is why I left it alone here. - The direct read bypasses the Options API filter chain.
pre_option_{$option},option_{$option}anddefault_option_{$option}can all legitimately producenull, and theSELECTignores them, so a filtered or drop-in options backend that leaves a stalewp_optionsrow behind could seeget()resurrect a retired record.
2 is a two-line fix in the same file, and 1 is small too. 3 and 4 are design calls about how this store relates to the Options API, so they are yours to make. Happy to patch any of them, here or on separate tickets, whichever you prefer.
AI assistance: Claude Code (Claude Opus 5) for the patch, the tests and this comment. I reviewed every line, ran the tests, and checked each claim against core myself.
This ticket was mentioned in PR #15329 on WordPress/wordpress-develop by @micahele.
3 days ago
#3
get_option() and get_site_option() return the default both when an option does not exist and when their query fails, and they cache the option as missing either way. WP_Secrets_Option_Store::get() read that as an absent secret, so during a failed read:
wp_get_secret()returnednullfor a stored secret.wp_set_secret()treated the secret as new, returnedtrue, and wrote a record with no previous version.
When the Options API returns the default, get() now checks the database directly:
- If the row exists, it returns the record and clears the cached "missing" entry.
- If there is no row, it returns
null. - If the read fails too, it returns
secret_store_unavailable, which the write path already treats as fatal.
Failure is taken from the return value of wpdb::query(), because wpdb::$last_error is not set on every failure path.
delete() now uses the same check, so it no longer reports success when it cannot tell whether the record still exists.
Relationship to #15301
#15301 was opened on this ticket first and has the same core approach. This PR differs in two ways:
- It clears the cached "missing" entry. #15301 leaves that entry in place on purpose. On multisite, the next
update_site_option()then goes throughadd_network_option(), which inserts a secondwp_sitemetarow, and later requests read the old one. I ran the same scenario against both: storeone, thentwo, fail one read, then writethreeto a network secret, then flush the cache and read it back.
| |
wp_set_network_secret()| Rows | Same request | After cache flush |
|---|---|---|---|---|
| #15301 (1ef7cc819c) |true| 2 |three|two|
| This PR |true| 1 |three|three|
- It also fixes
delete(), which the Trac comment on #15301 lists as left out of that patch.
The handling of a row with an empty or NULL value comes from that same comment and PR, and I had missed that case. If the committer prefers #15301, the cache cleanup and the test that flushes the cache could move there instead.
Known limits
- Replica lag is not fixed. The ticket lists "a replica that is behind", but that query succeeds with no rows, so it still looks like a missing secret.
- One extra query per miss. Every read of a missing secret now reaches the database, even with a persistent object cache, because the cached "missing" entry can't be trusted.
- The direct read skips Options API filters. It only runs after the Options API returned the default.
- The cache cleanup overlaps with #15186. That PR has a similar helper for the root key, so a shared one may make sense once both land.
list_names()is not changed.wpdb::get_col()always returns an array, so its error check can't fire and a failed query looks like an empty list.
Testing
The new tests make the record's SELECT fail through a query filter and run in both scopes, plus one multisite-only test. To run them:
vendor/bin/phpunit --group 66424 vendor/bin/phpunit -c tests/phpunit/multisite.xml --group 66424
- The initial tests failed on trunk before the fix. Removing the cache cleanup makes tests fail again.
--group secretshas no failures: 399 tests on single site and 400 on multisite, on PHP 8.2 and MySQL 8.4.- PHPCS is clean on both changed files. PHPStan is clean on the changed source file.
Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5.5
Used for: Reproducing the bug, writing the patch and tests, running the checks and the comparison above, and drafting this description.
#4
@
34 hours ago
Thanks @dhrupo and @MicahelE, two patches in two days.
Both take the same approach: when the Options API returns the default, read the row directly and take failure from wpdb::query()'s return value. I'd like to go with PR 15329 as the base, for the reason @MicahelE gives. A failed read leaves the record in notoptions, and on multisite the next update_site_option() then goes through add_network_option(). That function skips its existence check for an option in notoptions and inserts a second wp_sitemeta row, which has no unique key. I checked that path in option.php on trunk. Rotating a network secret after one failed read would leave two records, and later requests can read the old one. So the cache entry has to be cleared, not only bypassed.
Props should go to both of you. @dhrupo found the last_error problem and the empty meta_value case, and both are in 15329.
On the four items from comment:2:
delete(): fixed in 15329.list_names(): please fold it into 15329. It's the same bug: a failed listing reads as "no secrets", and the Site Health test that keeps undecryptable records visible then sees nothing. Taking failure from$wpdb->query(), asget()now does, would fix it.- Pruning
notoptions: needed, as above. 15186 (#66413) has a similar helper for the root key. Whichever lands second can use the other's, or a committer can merge them on commit. I don't think that should hold either up. - Bypassing the Options API filters: I'd accept this and say so in the docblock. The direct read only runs after the Options API has returned the default. A host that keeps options somewhere else should replace the store through
secrets.php, not filter the default store.
Replica lag is out of scope here. That read succeeds with no rows, and telling it apart is the database layer's job.
@dhrupo, could we close 15301 in favor of 15329? I'll review 15329 on GitHub.
@dhrupo commented on PR #15301:
12 hours ago
#5
Closing in favour of #15329, as agreed on the ticket: https://core.trac.wordpress.org/ticket/66424#comment:4
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)
WP_Secrets_Option_Store::get()read a record withget_option( $name, null )(orget_site_option()). Those return the default both when the option does not exist and when their query failed, so a failed read looked like "this secret does not exist":wp_get_secret()returnednullinstead ofWP_Error, which breaks the three-state contractWP_Secrets_Store::get()documents (the record if it exists,nullif it does not,WP_Errorif it could not be determined which). A caller that seesnullmay ask for the credential again or create a new one.wp_set_secret()treated the secret as new. It returnedtrueand wrote a record with no previous version, so a rotation in progress lost the value it depends on.When the options API reports the record as missing,
get()now reads the row directly to tell absence from a failed read, and returnssecret_store_unavailableif that read fails too.WP_Secrets_Libsodium_Provider::read_prior_record()already treats that code from the store as fatal, so the write aborts before anything is written.Two notes on the implementation:
wpdb::query()'s return value, notwpdb::$last_error.last_errorcannot carry this: when the server has gone away andcheck_connection()fails,wpdb::query()returnsfalseafterflush()has cleared the error string and without setting a new one, so a lost connection — the case this ticket is really about — would still have read as a missing secret.wpdb::query()returnsfalseon every failed read and a row count on every successful one.falsefor "no row", so a row that exists while holding no value is reported as a malformed record rather than as absence.wp_options.option_valueisNOT NULL, butwp_sitemeta.meta_valueis nullable, so this is reachable for network-scope secrets.This is the same shape as the root-key fix on #66413 (PR #15186), kept local to the option store. It deliberately does not prune the cache of options that do not exist, because
get()does the direct read on everynull, so a stale "missing" entry cannot make the store report absence.Testing
--group 66424, 6 new tests:Tests_Secrets_WPSecretsOptionStore:test_get_returns_an_error_when_the_record_read_fails,..._when_a_network_record_read_fails,..._when_the_read_never_reaches_the_database,test_get_returns_an_error_for_a_network_row_with_no_value(ms-required)Tests_Secrets_ThreeStateContract:test_a_failed_record_read_is_wp_error_not_null,test_a_failed_record_read_does_not_let_a_write_report_successEach fails on trunk. The write test is the clearest: on trunk
wp_set_secret()returnstrueduring a failed read.--group 66424: 6 tests, 21 assertions on multisite; 5 tests, 18 assertions on single site.--group secrets: 392 tests single site, 393 multisite, no failures.--group option: 446 tests, no failures.Query counts for the store, measured with
$wpdb->num_queries: an absent secret costs one extra read (2 instead of 1 on the first call in a request, 1 instead of 0 on a repeat, since the direct read does not use thenotoptionscache). A stored secret is unchanged: 1 uncached, 0 cached.Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5
Used for: Writing the tests and the patch, and running the checks above. I reviewed every line and checked the results myself.