Skip to content

fix(web): stop an unrelated config edit from erasing a plugin's secret - #478

Closed
ChuckBuilds wants to merge 1 commit into
mainfrom
fix/blank-secrets-erase-stored-credentials
Closed

fix(web): stop an unrelated config edit from erasing a plugin's secret#478
ChuckBuilds wants to merge 1 commit into
mainfrom
fix/blank-secrets-erase-stored-credentials

Conversation

@ChuckBuilds

Copy link
Copy Markdown
Owner

Saving any plugin setting erases that plugin's API key

Change the city on the weather plugin, and the weather API key is gone. The
plugin keeps rendering until its next fetch, then fails with no indication that
a credential was destroyed — and the field it was typed into still looks the
same afterwards, because it renders masked either way.

Reproduced end to end through the real endpoint, not the helpers:

after saving the key : 'REAL-KEY-0123456789'
after editing city   : ''            <-- on pristine main

Why nothing stopped it

Every step is individually reasonable, which is why this survived:

step behaviour
pages_v3.py:740 masks secrets before rendering the form — fail closed, correct
the form hx-post, so htmx submits every field, including the blanked one
_parse_value "preserve empty string instead of None" for optional strings — deliberate
separate_secrets routes {'api_key': ''} into secrets_config, a truthy dict
deep_merge writes the blank over the stored credential

The blank does not even require the round-trip. merge_with_defaults injects
the schema's api_key default ("") into every save, so a client that never
sends the field still erases it.

The fix already existed

remove_empty_secrets() is in secret_helpers.py with seven unit tests and a
docstring describing this exact scenario:

clients "will send those empty strings back. This filter strips them so that
existing stored secrets are not overwritten with blanks."

It was called from no production code. Its counterpart mask_secret_fields
was wired in; the half that protects the write path was not. This wires it into
both save paths that merge into the secrets file (save_plugin_config and the
plugin branch of save_main_config).

What changes for users

A blank now means "unchanged" rather than "delete" — the contract the helper's
own tests already describe. The cost is that a secret can no longer be cleared
by emptying the field. That needs its own affordance, and it should: a control
that destroys credentials as a side effect of ordinary edits is not a way to
clear them.

Verification

  • New test_an_unrelated_edit_does_not_erase_a_stored_secret drives the real
    Flask endpoint; reverting the guard makes it fail with '' read back.
  • test_a_secret_can_still_be_changed guards the over-correction — a real new
    value must still save.
  • test_secret_count_message_counts_top_level_keys changed 2 → 1. It was
    counting the injected blank as a "saved secret field", pinning the bug's
    visible edge as expected behaviour; the comment now records that.
  • 262 web tests pass.

Related, not fixed here

save_main_config logs the entire POSTed body at ERROR on every save
(logging.error(f"DEBUG: save_main_config received data: {data}"), plus full
request headers). That puts credentials in the journal and is debug output
shipped at ERROR level. Separate change.

Saving any field on a plugin's config form destroyed that plugin's stored
credential. On a rig with a weather API key, changing the city silently
emptied the key, and the plugin stopped working at the next fetch with no
indication why.

The path had no guard at any step. The config partial masks secrets before
rendering (pages_v3.py:740), so the browser posts them back blank; _parse_value
deliberately preserves "" for optional string fields; separate_secrets routes
that "" into secrets_config, which is a truthy dict; deep_merge writes it over
the stored value; save_raw_file_content persists it.

The blank does not even need the round-trip. merge_with_defaults injects the
schema's api_key default ("") into every save, so a client that never sends
the field at all still erases it. test_secret_count_message_counts_top_level_keys
was counting exactly that injected blank as a saved secret field -- the visible
edge of the bug, pinned as expected behaviour.

remove_empty_secrets() already existed for this, with seven unit tests and a
docstring describing this precise scenario ("clients will send those empty
strings back ... so that existing stored secrets are not overwritten with
blanks"). It was never wired into a call site. This wires it into both save
paths that merge into the secrets file.

A blank now means "unchanged" rather than "delete", which is the same contract
the helper's tests already describe. The cost is that a secret can no longer be
cleared by emptying the field; clearing needs its own affordance, since a
control that erases credentials as a side effect of ordinary edits is not one.

Verified by reverting the guard: the new round-trip test then fails with the
stored key read back as ''. 262 web tests pass with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@ChuckBuilds, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 15 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 002f1609-a1b1-42ad-a220-9378ce0042ef

📥 Commits

Reviewing files that changed from the base of the PR and between cf0a551 and 95bd8a1.

📒 Files selected for processing (2)
  • test/web_interface/test_api_v3_secret_roundtrip.py
  • web_interface/blueprints/api_v3.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codacy-production

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@ChuckBuilds

Copy link
Copy Markdown
Owner Author

Superseded by #485, which combines the seven api_v3.py PRs so they do not conflict with each other. Every change from this PR is verified present on that branch; the branch here is untouched if you want to compare.

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