mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-22 10:58:15 +00:00
Stop array-item secrets being wiped, and logging them
Three review findings from #485 that I missed when addressing that PR; it has since merged, so they land here. 1. Array-item secrets destroyed by any unrelated save (data loss). remove_empty_secrets recursed into dicts but let a list fall through to the scalar branch and kept it verbatim. Lists merge by *replacement*, so the blanks the masked form posts back went straight over the stored array: stored [{"name":"a","token":"REAL-A"}, {"name":"b","token":"REAL-B"}] posted [{"name":"a","token":""}, {"name":"b","token":""}] merged [{"name":"a","token":""}, {"name":"b","token":""}] -> both credentials gone Same failure as the scalar api_key case fixed earlier, one container deeper. Lists now prune element-wise, and a list with nothing real in it is dropped so the stored one is left alone. Where one entry does change, the new merge_secrets merges by index instead of replacing. Two details the first attempt got wrong, both caught by existing tests: - An emptied dict item must stay {}, not None. ConfigManager's _strip_secrets_recursive treats a secrets list as *parallel* to the regular one ({} = "item i has no secrets"); a None makes it stop looking parallel, and it then drops the whole key from the main config -- silently deleting the items' non-secret fields too. - The incoming list's length wins. The regular config's list is authoritative about how many items exist, so preserving surplus stored entries would let the two fall out of step and make deleting an entry impossible. 2. Submitted credentials written to the journal (security). save_plugin_config logged `Full config: {plugin_config}` at INFO and `Config that failed: {plugin_config}` at ERROR. Both run before separate_secrets, so plugin_config still held the values just typed into the form. Now keys only. Swept the rest of web_interface/ and src/ for the same shape -- these were the only two. 3. Restart banner kept stale wording. showRestartPending() cleared the stored custom text but left the DOM element alone, so a config save could show the previous update's message. The default is read back from the server-rendered copy rather than duplicated in JS, so the template stays the one owner of the string. Verified: 556 passed, 1 skipped across the web suite. Mutation-checked -- reverting api_v3 fails the logging guard and the array-merge test; reverting either half of the secret_helpers change fails the unit tests. New end-to-end coverage drives the real endpoint, not just the helpers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
This commit is contained in:
co-authored by
Claude Opus 5
parent
9c0c0dc851
commit
d64fa9761a
@@ -229,6 +229,44 @@ class TestSavePluginConfig:
|
||||
"REAL-KEY-0123456789", "an unrelated edit destroyed the API key"
|
||||
assert env.fresh_load()[PLUGIN_ID]["city"] == "Dallas"
|
||||
|
||||
def test_an_unrelated_edit_does_not_erase_array_item_secrets(self, env):
|
||||
"""The scalar api_key case above, but for a list of credentials.
|
||||
|
||||
remove_empty_secrets recursed into dicts only, so a list went into
|
||||
deep_merge untouched -- and lists merge by *replacement*. Saving any
|
||||
unrelated field posted [{"token": ""}, ...] straight over the stored
|
||||
array and destroyed every token in it at once.
|
||||
"""
|
||||
assert self._save(env, {"accounts": [
|
||||
{"name": "a", "token": "REAL-A"},
|
||||
{"name": "b", "token": "REAL-B"},
|
||||
], "city": "Austin"}).status_code == 200
|
||||
|
||||
# the user changes the city; both masked tokens ride along blank
|
||||
assert self._save(env, {"accounts": [
|
||||
{"name": "a", "token": ""},
|
||||
{"name": "b", "token": ""},
|
||||
], "city": "Dallas"}).status_code == 200
|
||||
|
||||
merged = env.fresh_load()[PLUGIN_ID]
|
||||
assert [a.get("token") for a in merged["accounts"]] == \
|
||||
["REAL-A", "REAL-B"], "an unrelated edit destroyed the array secrets"
|
||||
assert [a["name"] for a in merged["accounts"]] == ["a", "b"]
|
||||
assert merged["city"] == "Dallas"
|
||||
|
||||
def test_one_array_secret_can_be_changed_without_losing_the_rest(self, env):
|
||||
assert self._save(env, {"accounts": [
|
||||
{"name": "a", "token": "REAL-A"},
|
||||
{"name": "b", "token": "REAL-B"},
|
||||
]}).status_code == 200
|
||||
assert self._save(env, {"accounts": [
|
||||
{"name": "a", "token": ""},
|
||||
{"name": "b", "token": "NEW-B"},
|
||||
]}).status_code == 200
|
||||
|
||||
merged = env.fresh_load()[PLUGIN_ID]
|
||||
assert [a.get("token") for a in merged["accounts"]] == ["REAL-A", "NEW-B"]
|
||||
|
||||
def test_a_secret_can_still_be_changed(self, env):
|
||||
"""Dropping blanks must not stop a real new value from being saved."""
|
||||
self._save(env, {"api_key": "first-key"})
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""The validation logging ran before separate_secrets, so it logged credentials.
|
||||
|
||||
api_v3's plugin-config save logged `Full config: {plugin_config}` at INFO and
|
||||
`Config that failed: {plugin_config}` at ERROR. Both run *before*
|
||||
separate_secrets(), so plugin_config still held the values the user just typed
|
||||
into the form -- API keys and tokens went to the journal in clear text.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SOURCE = (Path(__file__).resolve().parents[2]
|
||||
/ "web_interface" / "blueprints" / "api_v3.py")
|
||||
|
||||
#: Objects that still hold submitted secret values at the point these log
|
||||
#: calls run. Interpolating one whole into a log message leaks credentials.
|
||||
UNREDACTED = ("plugin_config", "secrets_config", "current_secrets")
|
||||
|
||||
|
||||
def _logging_lines():
|
||||
for number, line in enumerate(SOURCE.read_text(encoding="utf-8").splitlines(), 1):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("#"):
|
||||
continue
|
||||
if re.match(r"logger\.(debug|info|warning|error|critical|exception)\(", stripped):
|
||||
yield number, stripped
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", UNREDACTED)
|
||||
def test_no_log_call_interpolates_a_whole_secret_bearing_object(name):
|
||||
# {name} or {name['k']} leaks; {list(name.keys())} and {len(name)} do not.
|
||||
bare = re.compile(r"\{" + re.escape(name) + r"(\[[^\]]*\])*\}")
|
||||
offenders = [f"{n}: {text}" for n, text in _logging_lines() if bare.search(text)]
|
||||
assert not offenders, (
|
||||
f"{name} still holds submitted secrets where these log calls run:\n "
|
||||
+ "\n ".join(offenders))
|
||||
|
||||
|
||||
def test_the_guard_would_notice_a_reintroduced_leak():
|
||||
"""Pin the detector itself, so a rewrite cannot silently stop matching."""
|
||||
bare = re.compile(r"\{" + re.escape("plugin_config") + r"(\[[^\]]*\])*\}")
|
||||
assert bare.search('logger.info(f"Full config: {plugin_config}")')
|
||||
assert bare.search("logger.error(f\"{plugin_config['api_key']}\")")
|
||||
assert not bare.search('logger.info(f"{list(plugin_config.keys())}")')
|
||||
@@ -17,6 +17,7 @@ from src.web_interface.secret_helpers import (
|
||||
separate_secrets,
|
||||
mask_secret_fields,
|
||||
mask_all_secret_values,
|
||||
merge_secrets,
|
||||
remove_empty_secrets,
|
||||
)
|
||||
|
||||
@@ -239,3 +240,67 @@ class TestRemoveEmptySecrets:
|
||||
def test_keeps_falsey_non_string_values(self):
|
||||
# 0 and False are neither None nor blank strings — they are kept.
|
||||
assert remove_empty_secrets({"a": 0, "b": False}) == {"a": 0, "b": False}
|
||||
|
||||
|
||||
class TestArrayItemSecrets:
|
||||
"""Lists merge by replacement, so a blanked array wipes stored credentials.
|
||||
|
||||
remove_empty_secrets recursed into dicts but let a list through untouched,
|
||||
so [{"token": ""}] went straight into deep_merge and overwrote the stored
|
||||
list. Saving any unrelated setting destroyed every token in the array.
|
||||
"""
|
||||
|
||||
STORED = {"accounts": [{"name": "a", "token": "REAL-A"},
|
||||
{"name": "b", "token": "REAL-B"}]}
|
||||
|
||||
def test_an_unrelated_save_keeps_every_stored_token(self):
|
||||
posted = {"accounts": [{"name": "a", "token": ""},
|
||||
{"name": "b", "token": ""}]}
|
||||
merged = merge_secrets(self.STORED, remove_empty_secrets(posted))
|
||||
assert [a["token"] for a in merged["accounts"]] == ["REAL-A", "REAL-B"]
|
||||
|
||||
def test_editing_one_entry_leaves_the_others_alone(self):
|
||||
posted = {"accounts": [{"name": "a", "token": ""},
|
||||
{"name": "b", "token": "NEW-B"}]}
|
||||
merged = merge_secrets(self.STORED, remove_empty_secrets(posted))
|
||||
assert [a["token"] for a in merged["accounts"]] == ["REAL-A", "NEW-B"]
|
||||
|
||||
def test_a_new_entry_is_appended(self):
|
||||
posted = {"accounts": [{"name": "a", "token": ""},
|
||||
{"name": "b", "token": ""},
|
||||
{"name": "c", "token": "NEW-C"}]}
|
||||
merged = merge_secrets(self.STORED, remove_empty_secrets(posted))
|
||||
assert [a["token"] for a in merged["accounts"]] == \
|
||||
["REAL-A", "REAL-B", "NEW-C"]
|
||||
|
||||
def test_a_list_of_bare_strings_merges_by_index(self):
|
||||
merged = merge_secrets({"keys": ["K1", "K2", "K3"]},
|
||||
remove_empty_secrets({"keys": ["", "K2-NEW", ""]}))
|
||||
assert merged["keys"] == ["K1", "K2-NEW", "K3"]
|
||||
|
||||
def test_an_all_blank_list_is_dropped_entirely(self):
|
||||
posted = {"accounts": [{"token": ""}, {"token": ""}]}
|
||||
assert "accounts" not in remove_empty_secrets(posted)
|
||||
|
||||
def test_plain_dict_secrets_are_unaffected(self):
|
||||
merged = merge_secrets({"api_key": "OLD", "other": "keep"},
|
||||
remove_empty_secrets({"api_key": "", "other": "changed"}))
|
||||
assert merged == {"api_key": "OLD", "other": "changed"}
|
||||
|
||||
def test_a_removed_entry_takes_its_secret_with_it(self):
|
||||
"""The regular config's list is authoritative about how many items
|
||||
exist, and the secrets list runs parallel to it -- see
|
||||
ConfigManager._strip_secrets_recursive. So a shorter incoming list
|
||||
must shorten the stored secrets too, or the two fall out of step."""
|
||||
posted = {"accounts": [{"name": "a", "token": "NEW-A"}]}
|
||||
merged = merge_secrets(self.STORED, remove_empty_secrets(posted))
|
||||
assert [a["token"] for a in merged["accounts"]] == ["NEW-A"]
|
||||
|
||||
def test_an_emptied_item_stays_a_dict_not_none(self):
|
||||
"""None there stops the list looking parallel, and
|
||||
_strip_secrets_recursive then drops the whole key from the main
|
||||
config -- deleting the item's non-secret fields as well."""
|
||||
pruned = remove_empty_secrets(
|
||||
{"accounts": [{"token": "real"}, {"token": ""}]})
|
||||
assert pruned["accounts"] == [{"token": "real"}, {}]
|
||||
assert None not in pruned["accounts"]
|
||||
|
||||
Reference in New Issue
Block a user