Skip to content

Secrets: Tell a failed record read apart from a missing secret. - #15301

Open
dhrupo wants to merge 1 commit into
WordPress:trunkfrom
dhrupo:66424-secret-store-read-failure
Open

dhrupo wants to merge 1 commit into
WordPress:trunkfrom
dhrupo:66424-secret-store-read-failure

Conversation

@dhrupo

@dhrupo dhrupo commented Oct 31, 2026

Copy link
Copy Markdown

WP_Secrets_Option_Store::get() read a record with get_option( $name, null ) (or get_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() returned null instead of WP_Error, which breaks the three-state contract WP_Secrets_Store::get() documents (the record if it exists, null if it does not, WP_Error if it could not be determined which). A caller that sees null may ask for the credential again or create a new one.
  • wp_set_secret() treated the secret as new. It returned true and 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 returns secret_store_unavailable if 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:

  • Failure comes from wpdb::query()'s return value, not wpdb::$last_error. last_error cannot carry this: when the server has gone away and check_connection() fails, wpdb::query() returns false after flush() 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() returns false on every failed read and a row count on every successful one.
  • The helper returns false for "no row", so a row that exists while holding no value is reported as a malformed record rather than as absence. wp_options.option_value is NOT NULL, but wp_sitemeta.meta_value is 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 every null, 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_success

Each fails on trunk. The write test is the clearest: on trunk wp_set_secret() returns true during 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.
  • PHPCS clean on the three changed files. PHPStan clean on the changed source file.

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 the notoptions cache). 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.

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.
@github-actions

Copy link
Copy Markdown

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:

Props dhrupo.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.


Props Bot commands

You can trigger Props Bot actions by commenting on this PR:

  • @propsbot add <username> to give props to a GitHub user. Requires write access.
  • @propsbot remove <username> to leave a user out of the props. Requires write access.
  • @propsbot refresh to refresh this list. Adding the props-bot label works too.

@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The 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

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

@ericmann ericmann left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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.

2 participants