Skip to content

Commit 61ff20f

Browse files
EstrellaXDclaude
andcommitted
fix(api): preserve masked passwords on config save and allow private IPs in setup (#995, #1001)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 17de8fa commit 61ff20f

4 files changed

Lines changed: 315 additions & 148 deletions

File tree

backend/src/module/api/config.py

Lines changed: 29 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
logger = logging.getLogger(__name__)
1212

1313
_SENSITIVE_KEYS = ("password", "api_key", "token", "secret")
14+
_MASK = "********"
15+
16+
17+
def _is_sensitive(key: str) -> bool:
18+
return any(s in key.lower() for s in _SENSITIVE_KEYS)
1419

1520

1621
def _sanitize_dict(d: dict) -> dict:
@@ -19,36 +24,34 @@ def _sanitize_dict(d: dict) -> dict:
1924
for k, v in d.items():
2025
if isinstance(v, dict):
2126
result[k] = _sanitize_dict(v)
22-
elif isinstance(v, str) and any(s in k.lower() for s in _SENSITIVE_KEYS):
23-
result[k] = "********"
27+
elif isinstance(v, list):
28+
result[k] = [
29+
_sanitize_dict(item) if isinstance(item, dict) else item for item in v
30+
]
31+
elif isinstance(v, str) and _is_sensitive(k):
32+
result[k] = _MASK
2433
else:
2534
result[k] = v
2635
return result
2736

2837

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 = {}
38+
def _restore_masked(incoming: dict, current: dict) -> dict:
39+
"""Replace masked sentinel values with real values from current config."""
4040
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
41+
if isinstance(v, dict) and isinstance(current.get(k), dict):
42+
_restore_masked(v, current[k])
43+
elif isinstance(v, list) and isinstance(current.get(k), list):
44+
cur_list = current[k]
45+
for i, item in enumerate(v):
46+
if (
47+
isinstance(item, dict)
48+
and i < len(cur_list)
49+
and isinstance(cur_list[i], dict)
50+
):
51+
_restore_masked(item, cur_list[i])
52+
elif v == _MASK and _is_sensitive(k):
53+
incoming[k] = current.get(k, v)
54+
return incoming
5255

5356

5457
@router.get("/get", dependencies=[Depends(get_current_user)])
@@ -63,10 +66,8 @@ async def get_config():
6366
async def update_config(config: Config):
6467
"""Persist and reload configuration from the supplied payload."""
6568
try:
66-
incoming = config.dict()
67-
current = settings.dict()
68-
restored = _restore_sensitive(incoming, current)
69-
settings.save(config_dict=restored)
69+
config_dict = _restore_masked(config.dict(), settings.dict())
70+
settings.save(config_dict=config_dict)
7071
settings.load()
7172
# update_rss()
7273
logger.info("Config updated")

backend/src/module/api/setup.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,6 @@ async def test_downloader(req: TestDownloaderRequest):
132132

133133
scheme = "https" if req.ssl else "http"
134134
host = req.host if "://" in req.host else f"{scheme}://{req.host}"
135-
_validate_url(host)
136135

137136
try:
138137
async with httpx.AsyncClient(timeout=5.0) as client:

0 commit comments

Comments
 (0)