Skip to content

Commit 62ee9f0

Browse files
authored
Merge pull request #997 from HuajunGao/fix/config-sensitive-field-overwrite
2 parents ded24b1 + 6fb8421 commit 62ee9f0

2 files changed

Lines changed: 124 additions & 2 deletions

File tree

backend/src/module/api/config.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,31 @@ def _sanitize_dict(d: dict) -> dict:
2626
return result
2727

2828

29+
def _restore_sensitive(incoming: dict, current: dict) -> dict:
30+
"""Replace masked '********' values with the real values from current config.
31+
32+
When the frontend submits a config update it sends back the masked
33+
placeholder for every sensitive field (password, token, …). Saving that
34+
placeholder verbatim would overwrite the real credential with the literal
35+
string '********'. This function walks the incoming dict and, wherever it
36+
finds the placeholder, substitutes the value that is already stored in the
37+
running settings.
38+
"""
39+
result = {}
40+
for k, v in incoming.items():
41+
if isinstance(v, dict):
42+
result[k] = _restore_sensitive(v, current.get(k, {}))
43+
elif (
44+
isinstance(v, str)
45+
and v == "********"
46+
and any(s in k.lower() for s in _SENSITIVE_KEYS)
47+
):
48+
result[k] = current.get(k, v)
49+
else:
50+
result[k] = v
51+
return result
52+
53+
2954
@router.get("/get", dependencies=[Depends(get_current_user)])
3055
async def get_config():
3156
"""Return the current configuration with sensitive fields masked."""
@@ -38,7 +63,10 @@ async def get_config():
3863
async def update_config(config: Config):
3964
"""Persist and reload configuration from the supplied payload."""
4065
try:
41-
settings.save(config_dict=config.dict())
66+
incoming = config.dict()
67+
current = settings.dict()
68+
restored = _restore_sensitive(incoming, current)
69+
settings.save(config_dict=restored)
4270
settings.load()
4371
# update_rss()
4472
logger.info("Config updated")

backend/src/test/test_api_config.py

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from fastapi.testclient import TestClient
88

99
from module.api import v1
10-
from module.api.config import _sanitize_dict
10+
from module.api.config import _restore_sensitive, _sanitize_dict
1111
from module.models.config import Config
1212
from module.security.api import get_current_user
1313

@@ -360,3 +360,97 @@ def test_get_config_masks_sensitive_fields(self, authed_client):
360360
assert data["downloader"]["password"] == "********"
361361
# OpenAI api_key should be masked (it's an empty string but still masked)
362362
assert data["experimental_openai"]["api_key"] == "********"
363+
364+
365+
# ---------------------------------------------------------------------------
366+
# _restore_sensitive unit tests
367+
# ---------------------------------------------------------------------------
368+
369+
370+
class TestRestoreSensitive:
371+
def test_restores_masked_password(self):
372+
"""Masked password is replaced with the real value from current config."""
373+
incoming = {"password": "********"}
374+
current = {"password": "realpassword"}
375+
result = _restore_sensitive(incoming, current)
376+
assert result["password"] == "realpassword"
377+
378+
def test_non_masked_password_kept(self):
379+
"""A newly supplied password (not the placeholder) is kept as-is."""
380+
incoming = {"password": "newpassword"}
381+
current = {"password": "oldpassword"}
382+
result = _restore_sensitive(incoming, current)
383+
assert result["password"] == "newpassword"
384+
385+
def test_non_sensitive_key_passed_through(self):
386+
"""Non-sensitive keys are never touched."""
387+
incoming = {"host": "192.168.1.1", "ssl": False}
388+
current = {"host": "10.0.0.1", "ssl": True}
389+
result = _restore_sensitive(incoming, current)
390+
assert result["host"] == "192.168.1.1"
391+
assert result["ssl"] is False
392+
393+
def test_nested_dict_restored(self):
394+
"""Masked values inside nested dicts are restored recursively."""
395+
incoming = {"downloader": {"host": "192.168.1.1", "password": "********"}}
396+
current = {"downloader": {"host": "10.0.0.1", "password": "realpassword"}}
397+
result = _restore_sensitive(incoming, current)
398+
assert result["downloader"]["host"] == "192.168.1.1"
399+
assert result["downloader"]["password"] == "realpassword"
400+
401+
def test_missing_key_in_current_keeps_placeholder(self):
402+
"""If a sensitive key has no counterpart in current, keep the incoming value."""
403+
incoming = {"password": "********"}
404+
current = {}
405+
result = _restore_sensitive(incoming, current)
406+
assert result["password"] == "********"
407+
408+
def test_update_config_preserves_password_when_masked(self, authed_client, mock_settings):
409+
"""PATCH /config/update must not overwrite a real password with '********'.
410+
411+
Reproduces the bug where the frontend sends back the masked placeholder
412+
and the backend used to save it verbatim, corrupting the stored credential.
413+
"""
414+
mock_settings.dict.return_value = {
415+
"program": {"rss_time": 900, "rename_time": 60, "webui_port": 7892},
416+
"downloader": {
417+
"type": "qbittorrent",
418+
"host": "192.168.1.1:8080",
419+
"username": "admin",
420+
"password": "realpassword",
421+
"path": "/downloads",
422+
"ssl": True,
423+
},
424+
"rss_parser": {"enable": True, "filter": [], "language": "zh"},
425+
"bangumi_manage": {"enable": True, "eps_complete": False, "rename_method": "pn", "group_tag": False, "remove_bad_torrent": False},
426+
"log": {"debug_enable": False},
427+
"proxy": {"enable": False, "type": "http", "host": "", "port": 0, "username": "", "password": ""},
428+
"notification": {"enable": False, "type": "telegram", "token": "", "chat_id": ""},
429+
"experimental_openai": {"enable": False, "api_key": "", "api_base": "https://api.openai.com/v1", "api_type": "openai", "api_version": "2023-05-15", "model": "gpt-3.5-turbo", "deployment_id": ""},
430+
}
431+
# Frontend sends back masked password and ssl changed to False
432+
payload = {
433+
"program": {"rss_time": 900, "rename_time": 60, "webui_port": 7892},
434+
"downloader": {
435+
"type": "qbittorrent",
436+
"host": "192.168.1.1:8080",
437+
"username": "admin",
438+
"password": "********", # <-- masked placeholder from frontend
439+
"path": "/downloads",
440+
"ssl": False, # <-- the actual change the user made
441+
},
442+
"rss_parser": {"enable": True, "filter": [], "language": "zh"},
443+
"bangumi_manage": {"enable": True, "eps_complete": False, "rename_method": "pn", "group_tag": False, "remove_bad_torrent": False},
444+
"log": {"debug_enable": False},
445+
"proxy": {"enable": False, "type": "http", "host": "", "port": 0, "username": "", "password": ""},
446+
"notification": {"enable": False, "type": "telegram", "token": "", "chat_id": ""},
447+
"experimental_openai": {"enable": False, "api_key": "", "api_base": "https://api.openai.com/v1", "api_type": "openai", "api_version": "2023-05-15", "model": "gpt-3.5-turbo", "deployment_id": ""},
448+
}
449+
with patch("module.api.config.settings", mock_settings):
450+
response = authed_client.patch("/api/v1/config/update", json=payload)
451+
452+
assert response.status_code == 200
453+
# The dict passed to save() must have the real password, not '********'
454+
saved = mock_settings.save.call_args[1]["config_dict"]
455+
assert saved["downloader"]["password"] == "realpassword"
456+
assert saved["downloader"]["ssl"] is False

0 commit comments

Comments
 (0)