Repository navigation
Conversation
WP_Secrets_Option_Store::get() read a record with get_option( $name, null ), which returns the default both when the option does not exist and when its query failed. A failed read therefore looked like "this secret does not exist": wp_get_secret() returned null instead of WP_Error, breaking the three-state contract, and wp_set_secret() treated the secret as new, so a rotation in progress lost the version it depends on. When the options API reports the record as missing, read the row directly to tell absence from a failed read, and return secret_store_unavailable when that read fails too. The write path already treats that error from the store as fatal, so no write happens. Failure is taken from wpdb::query()'s return value rather than wpdb::$last_error. A lost connection returns from query() after flush() has cleared the error string and without setting a new one, so last_error would have missed the case this ticket is about. Fixes #66424.
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. Core Committers: Use this line as a base for the props when committing in SVN: To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. Props Bot commandsYou can trigger Props Bot actions by commenting on this PR:
|
Test using WordPress PlaygroundThe changes in this pull request can previewed and tested using a WordPress Playground instance. WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser. Some things to be aware of
For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation. |
ericmann
left a comment
There was a problem hiding this comment.
Thanks @dhrupo. Your analysis on the ticket shaped both patches. I've suggested going with #15329 because it clears the notoptions entry: without that, a multisite write after a failed read inserts a second wp_sitemeta row (details on the ticket). Your last_error regression case and the empty meta_value handling are both in 15329, and props should go to both of you.
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.Trac ticket: https://core.trac.wordpress.org/ticket/66424
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.
This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.