Skip to content

fix(web): plugin settings endpoints - a refused save no longer leaks into config.json; GET masks secrets - #742

Merged
ChuckBuilds merged 8 commits into
mainfrom
fix/plugin-config-endpoints
Oct 4, 2026
Merged

ChuckBuilds merged 8 commits into
mainfrom
fix/plugin-config-endpoints

Conversation

@ChuckBuilds

Copy link
Copy Markdown
Owner

Summary

Six bugs in the plugin settings endpoints, one commit each. The first is a secrets leak.

  1. A refused settings save stayed in memory, and the next save wrote it, secrets included, to config.json.
    • Cause: ConfigManager.load_config() returned its cached dict itself (the perf(config): mtime-signature fast path for load_config #410 fast path kept that aliasing). Handlers edit what they load, then validate. The plugin form save applied posted fields to a shallow .copy() whose nested dicts were the cache's. save_main_config also sets its checkboxes before it can refuse a bad auto_update_channel.
    • Effect: a refused value stayed in the cache. The next save of any other setting wrote it to disk, including a nested secret typed into the same form (mqtt.password, league.espn_s2, flightaware.api_key) in plain text. That secret never reached config_secrets.json, so nothing stripped it.
    • Fix: load_config() returns a private copy on both paths, and the saves keep a copy of what they're given. This fixes every route that edits before it validates, not just one.
    • Cost: a pickle round trip, 2.0 ms on ledpi's real 64 KiB config (deepcopy is 6.9 ms). load_config is not on the render path. get_config() still returns the live dict to display-process readers.
    • Callers checked: every caller in src/ and web_interface/ either only reads, or saves the dict it edited.
  2. GET /api/v3/plugins/config returned secrets unmasked, and accepted non-plugin ids.
  3. Array-of-object rows were parsed without their schema.
    • Cause: _get_schema_property couldn't step past an index (cities.0.timezone).
    • Effect: a geochron city with no timezone made every save fail, and a countdown named "2027" was stored as an int.
  4. A required secret with no default blocked every form save (youtube-stats api_key) unless the user retyped it. A blank text secret now saves as "unchanged".
  5. POST /plugins/config wrote into core sections. {"plugin_id": "display", ...} returned 200. It now gets a 400, and a non-string id gets a 400 instead of a 500.
  6. A text field holding "true", "[1, 2]" or "{}" was converted to bool/JSON before the schema check, and the save failed. A string field now keeps what was typed.

Tests

  • New: test/web_interface/test_plugin_config_endpoints.py, which runs a real ConfigManager and SchemaManager. Every class fails on main.
  • Also: test_config_load_cache.py::TestCallersGetACopy, and new _get_schema_property cases.
  • Changed: two existing tests asserted the aliasing itself; they now assert a copy.
  • Full suite on this branch alone: the same 62 failed / 6 errors as main, plus 37 new passes.

Validated on ledpi (Pi 4), on top of main ef69201

  • The Config Editor page and GET /plugins/config for youtube-stats, jellyfin and incoming-packages contain none of the device's 3 real secrets.
  • web_auth, github and display return 400.
  • A GET → POST round trip of youtube-stats left config_secrets.json unchanged.
  • POST /plugins/config with plugin_id: "display" or 5 returns 400.

Part of a bug sweep

This is one of 10 independent fix PRs from one sweep, all based on main ef69201.

  • Merge order: any. 45 pairwise test merges gave 0 conflicts, and each PR also merges cleanly with fix(ipc): ticks carry the volatile timestamps, so current-status stays known over the socket #737.
  • CHANGELOG: each PR adds its bullet at a different place in Unreleased → Fixes, so squash-merging them one after another needs no conflict fixing.
  • Full suite (Windows), all 10 merged together vs plain main: the same 62 failures and 6 errors on both. These are the known Windows path and file-locking tests. 191 more tests pass.
    • One extra failure in that run, test_backup_manager.py::test_create_backup_contents (os.replace → WinError 5 on a temp zip), was a Windows file-lock flake. It passes on rerun, and nothing here touches create_backup.
  • ledpi: all 10 together ran on ledpi (Pi 4) on top of main, with a clean start and no errors or render stalls in the journal. ledpi is back on plain main.

🤖 Generated with Claude Code

ChuckBuilds and others added 6 commits October 3, 2026 20:57
ConfigManager.load_config() returned its cached self.config itself (the
mtime fast path from #410 kept the full path's aliasing). Web handlers
edit what they load and then validate: the plugin form save applies the
posted fields to the loaded section (a shallow .copy(), so nested dicts
were the cache's own), and save_main_config sets its checkboxes before
it checks auto_update_channel. When the save was refused, the edit
stayed in the cache the fast path serves, and the next save of any
other setting wrote it to config.json: the refused value, and a nested
secret typed into the same form (mqtt.password, league.espn_s2,
flightaware.api_key) in plain text, since it never reached
config_secrets.json to be stripped. The form also reloaded showing the
refused values.

load_config() now returns a private copy on both paths, and
save_config/save_config_atomic keep a copy of what they were given, so
nothing a caller edits reaches the cache unless it is saved. Fixing it
here rather than in each handler covers every route that edits before it
validates. No caller relies on editing the cache without saving: every
src/ and web_interface/ caller either reads, or saves the dict it
edited. get_config() still returns the live dict for the display
process's readers.

The copy is a pickle round trip: on a Pi 4 with its real 64 KiB config,
2.0 ms against 6.9 ms for copy.deepcopy (json round trip 3.4 ms). Two
tests asserted the aliasing itself and now assert a copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The route returned the plugin's section as load_config() has it, with
config_secrets.json merged in: API keys and tokens went out in plain
text. #276 masked them here; #330's rewrite of the route dropped it,
while the settings page and GET /config/secrets kept masking. It also
took any plugin_id, so ?plugin_id=web_auth returned the login's
cookie-signing key and password hash, and ?plugin_id=github the Plugin
Store token, which GET /config/main strips and redacts.

The route now refuses what _non_plugin_id_error refuses for reset and
uninstall (core sections, malformed ids) with a 400, and blanks x-secret
fields with mask_secret_fields after the defaults merge, as the page
does. A plugin with no schema has its credential-named fields blanked by
_redact_credentials, as GET /config/main does. Blank rather than the
bullets of GET /config/secrets: the save drops a blank secret as
"unchanged" (remove_empty_secrets) but would store the bullets, so the
response must post back as it came. Tested: GET, then POST the response
unchanged, keeps every stored secret.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An array of objects drawn as a table posts each cell as
"cities.0.timezone". _get_schema_property stopped at "cities" (an array,
not an object with properties), so _parse_form_value_with_schema got no
schema for the cell and guessed: a blank optional text cell became None
and a text cell holding digits became an int. Validation refused both,
so every save of the page failed for as long as such a row existed --
geochron's city without a timezone, a countdown named "2027". A secret
cell is always drawn blank, so a plugin with secrets in its rows could
not be saved from the form at all.

The lookup now steps from an index segment into the array's items: to
the item schema itself for "color.2", into its properties for a row
cell. Number, boolean and required cells convert as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The settings page draws a stored secret blank (mask_secret_fields) and
posts the blank back. _parse_form_value_with_schema turned a blank
optional string into "" -- which the save drops as unchanged
(remove_empty_secrets) -- but a blank required one into None. For a
secret that is required with no default (youtube-stats' api_key) that
None failed validation, so every save of the page was refused until the
key was typed in again.

A blank text secret (x-secret, type string) now parses to "", whatever
its required list says; a list or object secret keeps getting [] or {},
which the save drops the same way. Not _SKIP_FIELD: skipping keeps the
value load_config() merged in, and the save would then write it back to
config_secrets.json -- after a secret change the cached section can
still hold the old one, so that write reverted it. A test covers that
sequence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reset and uninstall check the plugin id with _non_plugin_id_error; the
save did not. {"plugin_id": "display", "config": {...}} found no schema,
so nothing was validated or filtered, and the body was merged into the
core display section along with "enabled": true -- rows: "banana"
included. A plugin_id that was not a string (a list, an object, a number)
reached config.get() or the schema lookup, raised TypeError, and came
back as a 500.

Both the JSON and the form path now call _non_plugin_id_error first and
answer its 400.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_parse_form_value_with_schema guessed before it consulted the schema:
"true"/"false" became booleans, and a value starting with "[" or "{"
that parsed as JSON became a list or object, whatever the field's type.
A text setting holding "true", "False", "[1, 2]" or "{}" was then
refused by validation ("Expected type string, got bool"), and the save
with it.

A field whose schema type is string, or string-or-null, now returns the
posted text as it came. Every other type goes through the conversions as
before; numbers in text fields were already left alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

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

Next included review available in 13 minutes.

Check out review usage here.

View limit details

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

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: ae360877-b644-4aea-99ab-1601403fdff1
📥 Commits

Reviewing files that changed from the base of the PR and between ef69201 and 01f0ae6.

📒 Files selected for processing (8)
  • CHANGELOG.md
  • src/config_manager.py
  • test/test_config_load_cache.py
  • test/test_config_manager_secrets.py
  • test/web_interface/test_api_v3_helpers.py
  • test/web_interface/test_plugin_config_endpoints.py
  • web_interface/blueprints/api_v3/__init__.py
  • web_interface/blueprints/api_v3/plugin_config.py
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

codacy-production Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 28 complexity

Metric Results
Complexity 28

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 and others added 2 commits October 3, 2026 22:15
_private_copy was a pickle round trip. It only ever unpickled bytes it had
just made from our own dict, so nothing untrusted reached it, but it put
pickle in the config path and Codacy failed the PR for it (B301/B403).
The config is JSON data, so copying its dicts and lists is a full copy;
every other value is immutable. Measured on ledpi (Pi 4) with its real
60 KiB config: 2.11 ms, against 1.92 ms for pickle and 6.75 ms for
copy.deepcopy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ChuckBuilds
ChuckBuilds merged commit 0b039c8 into main Oct 4, 2026
15 checks passed
@ChuckBuilds
ChuckBuilds deleted the fix/plugin-config-endpoints branch October 4, 2026 02:30
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