Skip to content

Stop the settings sanitizer corrupting the stored client secret - #6

Merged
ibuilder merged 1 commit into
mainfrom
fix/settings-sanitizer-idempotency
Aug 8, 2026
Merged

Stop the settings sanitizer corrupting the stored client secret#6
ibuilder merged 1 commit into
mainfrom
fix/settings-sanitizer-idempotency

Conversation

@ibuilder

@ibuilder ibuilder commented Aug 8, 2026

Copy link
Copy Markdown
Owner

The bug

register_setting() attaches Settings::sanitize() to the sanitize_option_{$option} filter. WordPress runs that filter on every update_option() for the option — not just on the settings-form submit. That includes the plugin's own internal writes in Settings::set() and Settings::migrate_legacy().

The sanitizer encrypted the client secret unconditionally, so each of those writes re-encrypted the already-encrypted value, one layer at a time. After a couple of saves the secret no longer decrypted and the site silently lost its Procore connection, with no indication why.

Measured in a live install — after the admin screen registered the setting, a single migrate_legacy() write produced a triple-encrypted value:

raw stored    : pwp2:q7uAsoxCdUwBAd/uD6D6nz7Pq...  (len 233)
decrypt once  : pwp2:ckA19Hn9X/HHRCTm6EuqXTBK3...  (len 141)
decrypt twice : 'pwp2:PAjxg05JMuBes51Brx+BovGuw...'

Worst case this loses a 1.x upgrader's migrated credential during the upgrade itself.

The fix

Encryption is idempotent at that boundary. Encryption::is_encrypted() recognises cipher text by its prefix; sanitize() passes such a value through untouched while still encrypting genuine plaintext from the form.

Tests

Two regression tests, both confirmed to fail against the previous behaviour before the fix was applied (I reverted the fix, watched them fail, then restored it):

  • sanitize() fed its own output repeatedly still yields the same plaintext
  • a form save followed by three internal writes leaves the secret usable

82 tests, 340 assertions, all green.

Why the earlier integration run missed it

That run never called register_setting() — under WP-CLI is_admin() is false, so SettingsPage::register() never fires and the sanitize filter was never attached. The faulty path is unreachable without it, and reachable in every real admin request. This run registered the setting explicitly, which is what exposed it.

Full test pass on this branch

PHP syntax 50 files, 0 failures
PHPCS WordPress-Extra + WordPress-Docs zero errors, zero warnings
PHPUnit 82 tests, 340 assertions
Integration (WordPress 7.0.3, clean install) 18/18 shortcodes render · 19/19 correctness · 8/8 transport & cache · 9/9 REST · 5/5 blocks · 19/19 admin · 7/7 migration · 2/2 uninstall · 0 PHP notices

🤖 Generated with Claude Code

register_setting() attaches Settings::sanitize() to the
sanitize_option_{$option} filter, and WordPress runs that filter on every
update_option() for the option — not just on the settings form submit.
That includes this plugin's own internal writes in Settings::set() and
Settings::migrate_legacy().

Because the sanitizer encrypted the client secret unconditionally, each of
those writes re-encrypted the already-encrypted value, one layer at a time.
After a couple of saves the secret no longer decrypted, and the site lost
its Procore connection with no indication why. Measured in a live install:
after the admin screen registered the setting, a single migrate_legacy()
write produced a triple-encrypted value.

Encryption is now idempotent at that boundary. Encryption::is_encrypted()
recognises cipher text by its prefix, and sanitize() passes such a value
through untouched while still encrypting genuine plaintext from the form.

Two regression tests cover it, and both were confirmed to fail against the
previous behaviour before the fix was applied:

- sanitize() fed its own output repeatedly still yields the same plaintext
- a settings write followed by three internal writes leaves the secret usable

This was only reachable with the setting registered, which is every real
admin request but was not the case in the earlier integration run — that
run never called register_setting(), which is why it passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ibuilder
ibuilder merged commit bfd79bb into main Aug 8, 2026
13 checks passed
@ibuilder
ibuilder deleted the fix/settings-sanitizer-idempotency branch August 8, 2026 13:42
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.

1 participant