Skip to content

Commit 6d394b7

Browse files
Merge pull request #5243 from springfall2008/fix/apps-page-reveal-secret-on-edit
fix(web): let /apps Edit reveal a masked credential instead of offering "xxx"
2 parents e9cee18 + 3e40aec commit 6d394b7

3 files changed

Lines changed: 209 additions & 18 deletions

File tree

‎apps/predbat/tests/test_web_apps_edit.py‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,14 @@ async def post(self):
6767
return self.postdata
6868

6969

70+
class FakeGetRequest:
71+
"""A minimal aiohttp-request stand-in exposing only the query string the handler reads."""
72+
73+
def __init__(self, query):
74+
"""Store the query parameters this request will hand back."""
75+
self.query = query
76+
77+
7078
def _load_yaml(path="apps.yaml"):
7179
"""Load a YAML file from the temporary working directory."""
7280
yaml = YAML()
@@ -502,6 +510,111 @@ def run_web_apps_edit_tests(my_predbat):
502510
print(" ERROR: the rotated credential was not written")
503511
failed += 1
504512

513+
# ---------------------------------------------------------------------
514+
# The page serving the mask meant clicking Edit on a credential filled the input with
515+
# "xxx": the real key could not be seen or amended, only retyped from scratch. The page
516+
# stays masked; Edit fetches the one real value through /apps_value instead.
517+
print("Test: credential rows are flagged for reveal-on-edit and still served masked")
518+
text = _render_apps_page(my_predbat, {"octopus_api_key": "sk-top-level-credential-0000", "chat": {"providers": {"openrouter": {"api_key": "sk-live-nested-credential-1234", "model": "some-model"}}}})
519+
if "sk-top-level-credential-0000" in text or "sk-live-nested-credential-1234" in text:
520+
print(" ERROR: a credential reached the rendered page in the clear")
521+
failed += 1
522+
if not re.search(r'<tr id="row_\d+" data-arg-name="octopus_api_key" data-original-value="xxx" data-secret="1">', text):
523+
print(" ERROR: the top-level credential row should be masked and flagged data-secret")
524+
failed += 1
525+
if not re.search(r"data-nested-path='chat\.providers\.openrouter\.api_key' data-nested-original='xxx' data-secret='1'", text):
526+
print(" ERROR: the nested credential row should be masked and flagged data-secret")
527+
failed += 1
528+
if re.search(r"data-nested-path='chat\.providers\.openrouter\.model'[^>]*data-secret", text):
529+
print(" ERROR: a non-credential sibling must not be flagged data-secret")
530+
failed += 1
531+
532+
print("Test: /apps_value hands back the real value of the credential being edited")
533+
web_interface = _reset_fixture(my_predbat)
534+
for path, expected in (("chat.providers.openrouter.api_key", "sk-live-nested-credential-1234"), ("forecast_solar[0].api_key", "fs-live-credential-5678"), ("forecast_solar[0].declination", "30")):
535+
result = json.loads(asyncio.run(web_interface.html_apps_value(FakeGetRequest({"path": path}))).text)
536+
if not result.get("success") or result.get("value") != expected:
537+
print(" ERROR: expected /apps_value for {} to return {}, got: {}".format(path, expected, result))
538+
failed += 1
539+
540+
print("Test: /apps_value refuses a missing path or a whole structure")
541+
for query in ({}, {"path": "chat.providers.nope"}, {"path": "chat.providers"}):
542+
result = json.loads(asyncio.run(web_interface.html_apps_value(FakeGetRequest(query))).text)
543+
if result.get("success") or "value" in result:
544+
print(" ERROR: expected /apps_value to refuse {}, got: {}".format(query, result))
545+
failed += 1
546+
547+
print("Test: Edit reveals a flagged credential before opening the input")
548+
for function, attribute in (("editValue", "originalValue"), ("editNestedValue", "nestedOriginal")):
549+
edit_src = apps_js[apps_js.index("function {}(".format(function)) :]
550+
edit_src = edit_src[: edit_src.index("\n}\n")]
551+
if "row.dataset.secret === '1'" not in edit_src or "revealSecretValue(row, '{}'".format(attribute) not in edit_src:
552+
print(" ERROR: expected {} to fetch the real value of a data-secret row, got:\n{}".format(function, edit_src))
553+
failed += 1
554+
reveal_src = apps_js[apps_js.index("async function revealSecretValue(") :]
555+
reveal_src = reveal_src[: reveal_src.index("\n}\n")]
556+
if "fetch('./apps_value?path=' + encodeURIComponent(path))" not in reveal_src or "delete row.dataset.secret" not in reveal_src:
557+
print(" ERROR: expected revealSecretValue to fetch /apps_value and replace the masked original, got:\n{}".format(reveal_src))
558+
failed += 1
559+
560+
# A revealed credential is the first value that routinely holds quotes, braces or angle
561+
# brackets to reach the editor - the mask "xxx" never did (#5243 review)
562+
print("Test: a credential holding quote, brace and angle-bracket characters round-trips through reveal and save")
563+
awkward = 'pa"ss{word<x>&{0}'
564+
web_interface = _reset_fixture(my_predbat)
565+
web_interface.args["chat"]["providers"]["openrouter"]["api_key"] = awkward
566+
path = "chat.providers.openrouter.api_key"
567+
result = json.loads(asyncio.run(web_interface.html_apps_value(FakeGetRequest({"path": path}))).text)
568+
if not result.get("success") or result.get("value") != awkward:
569+
print(" ERROR: expected /apps_value to return the brace-bearing credential verbatim, got: {}".format(result))
570+
failed += 1
571+
else:
572+
result = _post_changes(web_interface, {path: {"rowId": 1001, "originalValue": awkward, "newValue": awkward + "2", "type": "string", "isNested": True, "path": path}})
573+
if not result.get("success") or _load_yaml()["pred_bat"]["chat"]["providers"]["openrouter"]["api_key"] != awkward + "2":
574+
print(" ERROR: the edited quote-bearing credential was not written intact, got: {}".format(result))
575+
failed += 1
576+
577+
print("Test: a digit-only credential saved as a revealed row keeps its leading zeros")
578+
web_interface = _reset_fixture(my_predbat)
579+
path = "forecast_solar[0].api_key"
580+
result = _post_changes(web_interface, {path: {"rowId": 1001, "originalValue": "0042", "newValue": "00430", "type": "string", "isNested": True, "path": path}})
581+
if not result.get("success") or str(_load_yaml()["pred_bat"]["forecast_solar"][0]["api_key"]) != "00430":
582+
print(" ERROR: expected the credential to be written as the string 00430, got: {} / {}".format(result, _load_yaml()["pred_bat"]["forecast_solar"][0]["api_key"]))
583+
failed += 1
584+
585+
print("Test: /apps_value refuses a negative index into an empty list rather than raising")
586+
web_interface = _reset_fixture(my_predbat)
587+
web_interface.args["forecast_solar"] = []
588+
result = json.loads(asyncio.run(web_interface.html_apps_value(FakeGetRequest({"path": "forecast_solar[-1].api_key"}))).text)
589+
if result.get("success") or "value" in result:
590+
print(" ERROR: expected a refusal for an index into an empty list, got: {}".format(result))
591+
failed += 1
592+
593+
print("Test: the editor sets a revealed value as a property and treats it as plain text")
594+
for function in ("editValue", "editNestedValue"):
595+
edit_src = apps_js[apps_js.index("function {}(".format(function)) :]
596+
edit_src = edit_src[: edit_src.index("\n}\n")]
597+
if 'value="${currentValue}"' in edit_src or "input.value = currentValue" not in edit_src:
598+
print(" ERROR: expected {} to assign input.value rather than interpolate the value into markup, got:\n{}".format(function, edit_src))
599+
failed += 1
600+
if "row.dataset.revealed !== '1' && currentValue" not in edit_src:
601+
print(" ERROR: expected {} to skip the entity dropdown for a revealed credential, got:\n{}".format(function, edit_src))
602+
failed += 1
603+
for function in ("saveValue", "saveNestedValue"):
604+
save_src = apps_js[apps_js.index("function {}(".format(function)) :]
605+
save_src = save_src[: save_src.index("\n}\n")]
606+
if "row.dataset.revealed === '1' ? 'string'" not in save_src:
607+
print(" ERROR: expected {} to save a revealed credential as a string, got:\n{}".format(function, save_src))
608+
failed += 1
609+
if "row.dataset.revealed = '1'" not in reveal_src:
610+
print(" ERROR: expected revealSecretValue to mark the row as revealed")
611+
failed += 1
612+
display_src = apps_js[apps_js.index("function getDisplayValueEntity(") :]
613+
display_src = display_src[: display_src.index("\n}\n")]
614+
if display_src.count("escapeHtml(") != 2:
615+
print(" ERROR: expected getDisplayValueEntity to escape both of its returns, got:\n{}".format(display_src))
616+
failed += 1
617+
505618
finally:
506619
os.chdir(original_dir)
507620
shutil.rmtree(temp_dir, ignore_errors=True)

‎apps/predbat/web.py‎

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@
9191
predbat_log_file_prev,
9292
is_secret_key,
9393
)
94-
from utils import is_data_numerical, ROOT_YAML_KEY, SECRET_MASK, YAML_DUMP_WIDTH, parse_yaml_path, update_nested_yaml_value # noqa: F401 - re-exported: moved to utils.py, agent_tools.py/chat_tools.py must not import from web.py
94+
from utils import is_data_numerical, ROOT_YAML_KEY, SECRET_MASK, YAML_DUMP_WIDTH, parse_yaml_path, resolve_nested_yaml_value, update_nested_yaml_value # noqa: F401 - re-exported: moved to utils.py, agent_tools.py/chat_tools.py must not import from web.py
9595
from const import TIME_FORMAT, TIME_FORMAT_DAILY, TIME_FORMAT_HA, MANUAL_RATE_MAX_MINUTES, MANUAL_TIME_MAX_MINUTES
9696
from predbat import THIS_VERSION_DISPLAY
9797
from component_base import ComponentBase
@@ -514,6 +514,7 @@ async def start(self):
514514
app.router.add_get("/log", self.html_log)
515515
app.router.add_get("/apps", self.html_apps)
516516
app.router.add_post("/apps", self.html_apps_post)
517+
app.router.add_get("/apps_value", self.html_apps_value)
517518
app.router.add_get("/charts", self.html_charts)
518519
app.router.add_get("/config", self.html_config)
519520
app.router.add_get("/entity", self.html_entity)
@@ -2950,9 +2951,10 @@ def render_type(self, arg, value, parent_path="", row_counter=None):
29502951
actions_cell += self.render_delete_button(nested_row_id)
29512952

29522953
raw_value = self.resolve_value_raw(key, nested_value)
2954+
secret_attr = self.secret_row_attr(key, nested_value, "'")
29532955

29542956
if nested_row_id is not None:
2955-
text += f"<tr id='nested_row_{nested_row_id}' data-nested-path='{nested_path_attr}' data-nested-original='{html_module.escape(str(raw_value))}'><td><b>{key}: </b></td><td id='nested_value_{nested_row_id}'>{self.render_type(key, nested_value, nested_path, row_counter)}</td><td>{actions_cell}</td></tr>\n"
2957+
text += f"<tr id='nested_row_{nested_row_id}' data-nested-path='{nested_path_attr}' data-nested-original='{html_module.escape(str(raw_value))}'{secret_attr}><td><b>{key}: </b></td><td id='nested_value_{nested_row_id}'>{self.render_type(key, nested_value, nested_path, row_counter)}</td><td>{actions_cell}</td></tr>\n"
29562958
else:
29572959
text += "<tr><td><b>{}: </b></td><td colspan='2'>{}</td></tr>\n".format(key, self.render_type(key, nested_value, nested_path, row_counter))
29582960
text += self.render_add_row("addDictKey", [dict_path], "Add setting", row_counter)
@@ -3752,6 +3754,47 @@ def is_editable_value(self, value):
37523754
return len(value) > 0 and any(self.is_editable_value(item) for item in value)
37533755
return False
37543756

3757+
def secret_row_attr(self, key, value, quote):
3758+
"""
3759+
Return the data-secret attribute for a row whose value html_apps() served masked, else "".
3760+
3761+
The page holds SECRET_MASK for a credential rather than the credential itself, so the
3762+
browser's Edit has to fetch the real value (/apps_value) before it can offer it - this flag
3763+
is how it knows to. mask_secret_args() replaces a secret key's whole value, list or dict
3764+
included, so a credential container (e.g. redact_strings) is also one masked row and is
3765+
flagged too; /apps_value refuses to hand a container back, so its Edit ends in that
3766+
refusal rather than letting the editor overwrite the whole list with a single string.
3767+
"""
3768+
if value == SECRET_MASK and is_secret_key(key):
3769+
return " data-secret={}1{}".format(quote, quote)
3770+
return ""
3771+
3772+
async def html_apps_value(self, request):
3773+
"""
3774+
Return the real value of one apps.yaml setting, for the /apps editor's Edit on a credential.
3775+
3776+
html_apps() serves credentials masked, so without this Edit could only offer "xxx" - the
3777+
user could neither see nor amend the key, and saving it back unchanged is refused. Only
3778+
the one path asked for is returned, and only when it is a single value, so the page itself
3779+
still never carries credentials and a whole container cannot be pulled in the clear. The
3780+
unmasked /debug_apps download already exposes the same values behind the same access.
3781+
3782+
The value is returned as stored, not passed through resolve_value_raw(): a credential is
3783+
literal text, and one holding a brace ("abc{def", "{0}") makes str.format() in
3784+
resolve_arg() raise ValueError/IndexError, which would surface as a 500 here.
3785+
"""
3786+
path = request.query.get("path", "")
3787+
if not path:
3788+
return web.json_response({"success": False, "message": "No path given"})
3789+
try:
3790+
value = resolve_nested_yaml_value(self.args, path)
3791+
except (KeyError, TypeError, ValueError, IndexError) as e:
3792+
# IndexError: a negative index into an empty list gets past the lookup's range check
3793+
return web.json_response({"success": False, "message": f"Path {path} not found: {str(e)}"})
3794+
if not self.is_editable_value(value) or isinstance(value, list):
3795+
return web.json_response({"success": False, "message": f"{path} is not a single value that can be edited here - edit apps.yaml directly"})
3796+
return web.json_response({"success": True, "value": str(value)})
3797+
37553798
def resolve_value_raw(self, arg, value):
37563799
if isinstance(value, str) and "{" in value:
37573800
text = self.base.resolve_arg(arg, value, indirect=False, quiet=True)
@@ -3870,8 +3913,8 @@ async def html_apps(self, request):
38703913
# For numerical values, show edit button
38713914
actions_cell = f'<button class="edit-button" onclick="editValue({row_id})">Edit</button>'
38723915

3873-
text += '<tr id="row_{}" data-arg-name="{}" data-original-value="{}"><td>{}</td><td id="value_{}">{}</td><td>{}</td></tr>\n'.format(
3874-
row_id, arg_attr, html_module.escape(str(raw_value)), arg, row_id, self.render_type(arg, value, "", row_counter), actions_cell
3916+
text += '<tr id="row_{}" data-arg-name="{}" data-original-value="{}"{}><td>{}</td><td id="value_{}">{}</td><td>{}</td></tr>\n'.format(
3917+
row_id, arg_attr, html_module.escape(str(raw_value)), self.secret_row_attr(arg, value, '"'), arg, row_id, self.render_type(arg, value, "", row_counter), actions_cell
38753918
)
38763919
row_id += 1
38773920

0 commit comments

Comments
 (0)