Skip to content

Security: Do not report a secret as missing when reading it fails - #15329

Open
MicahelE wants to merge 2 commits into
WordPress:trunkfrom
MicahelE:fix/66424-secrets-failed-read
Open

MicahelE wants to merge 2 commits into
WordPress:trunkfrom
MicahelE:fix/66424-secrets-failed-read

Conversation

@MicahelE

@MicahelE MicahelE commented Nov 1, 2026

Copy link
Copy Markdown

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() returned null for a stored secret.
  • wp_set_secret() treated the secret as new, returned true, 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:

  1. It clears the cached "missing" entry. Secrets: Tell a failed record read apart from a missing secret. #15301 leaves that entry in place on purpose. On multisite, the next update_site_option() then goes through add_network_option(), which inserts a second wp_sitemeta row, and later requests read the old one. I ran the same scenario against both: store one, then two, fail one read, then write three to a network secret, then flush the cache and read it back.

    wp_set_network_secret() Rows Same request After cache flush
    Secrets: Tell a failed record read apart from a missing secret. #15301 (1ef7cc8) true 2 three two
    This PR true 1 three three
  2. It also fixes delete(), which the Trac comment on Secrets: Tell a failed record read apart from a missing secret. #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 Secrets API: Do not replace the root key after a failed read #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 secrets has 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.

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.5
Used for: Reproducing the bug, writing the patch and tests, running the checks and the comparison above, and drafting this description.


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.

`get_option()` and `get_site_option()` return the default both when an option does not exist and when their query fails, and 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()` returned `null` for a stored secret and `wp_set_secret()` treated it as new, writing a record with no previous version.

When the Options API returns the default, the store now checks the database directly. If the row exists, it returns the record and clears the cached "missing" entry. If that read fails too, it returns a `secret_store_unavailable` error, which the write path already treats as fatal.

`WP_Secrets_Option_Store::delete()` now uses the same check, so it no longer reports success when it cannot tell whether the record still exists.

See #66424.
Adds tests that read a network secret back after the cache is flushed, which catches a second `wp_sitemeta` row left by writing through a stale "missing" cache entry.

A row with an empty or `NULL` value now reports `secret_record_malformed` rather than reading as an absent secret.

See #66424.
@github-actions

github-actions Bot commented Nov 1, 2026 •

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 micahele, ericmann.

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

github-actions Bot commented Nov 1, 2026

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 @MicahelE. I confirmed the multisite path on trunk: with the record in notoptions, add_network_option() skips get_network_option() and calls $wpdb->insert(), so 15301 as it stands leaves two rows. Clearing the entry in read_stored_value() is the right fix.

delete() going through get() also looks right. A malformed record is a WP_Error, not null, so a failed delete of a row that exists is still reported.

One request, from the ticket: please also make list_names() take failure from $wpdb->query(). Today get_col() always returns an array, so its error check can't fire, and a failed listing reads as an empty one.

@ericmann

ericmann commented Nov 6, 2026

Copy link
Copy Markdown

@MicahelE, r65272 changed the Secrets API tests, so this now likely conflicts. When you merge or rebase trunk, could you also:

  • give the new tests r65272's conventions: a summary, @covers and a unique message on each assertion;
  • make list_names() take failure from $wpdb->query(), as asked on the ticket. get_col() always returns an array, so a failed listing still reads as "no secrets".

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