Skip to content

Commit ccafd04

Browse files
authored
Merge pull request #1003 from EstrellaXD/3.2-dev
Release 3.2.5
2 parents e1b90c9 + 61ff20f commit ccafd04

6 files changed

Lines changed: 359 additions & 68 deletions

File tree

backend/src/module/api/config.py

Lines changed: 32 additions & 3 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,13 +24,36 @@ 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

38+
def _restore_masked(incoming: dict, current: dict) -> dict:
39+
"""Replace masked sentinel values with real values from current config."""
40+
for k, v in incoming.items():
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
55+
56+
2957
@router.get("/get", dependencies=[Depends(get_current_user)])
3058
async def get_config():
3159
"""Return the current configuration with sensitive fields masked."""
@@ -38,7 +66,8 @@ async def get_config():
3866
async def update_config(config: Config):
3967
"""Persist and reload configuration from the supplied payload."""
4068
try:
41-
settings.save(config_dict=config.dict())
69+
config_dict = _restore_masked(config.dict(), settings.dict())
70+
settings.save(config_dict=config_dict)
4271
settings.load()
4372
# update_rss()
4473
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:

backend/src/module/mcp/server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from mcp.server.sse import SseServerTransport
1616
from starlette.applications import Starlette
1717
from starlette.requests import Request
18+
from starlette.responses import Response
1819
from starlette.routing import Mount, Route
1920

2021
from .resources import RESOURCE_TEMPLATES, RESOURCES, handle_resource
@@ -64,6 +65,7 @@ async def handle_sse(request: Request):
6465
streams[1],
6566
server.create_initialization_options(),
6667
)
68+
return Response()
6769

6870

6971
def create_mcp_starlette_app() -> Starlette:

backend/src/module/models/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ class RSSParser(BaseModel):
5151
"""RSS feed parsing settings."""
5252

5353
enable: bool = Field(True, description="Enable RSS parser")
54-
filter: list[str] = Field(["720", r"\d+-\d"], description="Filter")
54+
filter: list[str] = Field(["720", r"\d+-\d+"], description="Filter")
5555
language: str = "zh"
5656

5757

backend/src/test/test_api_config.py

Lines changed: 228 additions & 19 deletions
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 _sanitize_dict, _restore_masked
1111
from module.models.config import Config
1212
from module.security.api import get_current_user
1313

@@ -306,24 +306,20 @@ def test_non_sensitive_keys_pass_through(self):
306306

307307
def test_nested_dict_recursed(self):
308308
"""Nested dicts are processed recursively."""
309-
result = _sanitize_dict({
310-
"downloader": {
311-
"host": "localhost",
312-
"password": "secret",
309+
result = _sanitize_dict(
310+
{
311+
"downloader": {
312+
"host": "localhost",
313+
"password": "secret",
314+
}
313315
}
314-
})
316+
)
315317
assert result["downloader"]["host"] == "localhost"
316318
assert result["downloader"]["password"] == "********"
317319

318320
def test_deeply_nested_dict(self):
319321
"""Deeply nested sensitive keys are masked."""
320-
result = _sanitize_dict({
321-
"level1": {
322-
"level2": {
323-
"api_key": "deep-secret"
324-
}
325-
}
326-
})
322+
result = _sanitize_dict({"level1": {"level2": {"api_key": "deep-secret"}}})
327323
assert result["level1"]["level2"]["api_key"] == "********"
328324

329325
def test_non_string_value_not_masked(self):
@@ -338,17 +334,33 @@ def test_empty_dict(self):
338334

339335
def test_mixed_sensitive_and_plain(self):
340336
"""Mix of sensitive and plain keys handled correctly."""
341-
result = _sanitize_dict({
342-
"username": "admin",
343-
"password": "secret",
344-
"host": "10.0.0.1",
345-
"token": "jwt-abc",
346-
})
337+
result = _sanitize_dict(
338+
{
339+
"username": "admin",
340+
"password": "secret",
341+
"host": "10.0.0.1",
342+
"token": "jwt-abc",
343+
}
344+
)
347345
assert result["username"] == "admin"
348346
assert result["host"] == "10.0.0.1"
349347
assert result["password"] == "********"
350348
assert result["token"] == "********"
351349

350+
def test_sanitize_list_of_dicts(self):
351+
"""Lists containing dicts are recursed into."""
352+
result = _sanitize_dict(
353+
{
354+
"providers": [
355+
{"type": "telegram", "token": "secret-token"},
356+
{"type": "bark", "token": "another-secret"},
357+
]
358+
}
359+
)
360+
assert result["providers"][0]["token"] == "********"
361+
assert result["providers"][1]["token"] == "********"
362+
assert result["providers"][0]["type"] == "telegram"
363+
352364
def test_get_config_masks_sensitive_fields(self, authed_client):
353365
"""GET /config/get response masks password and api_key fields."""
354366
test_config = Config()
@@ -360,3 +372,200 @@ def test_get_config_masks_sensitive_fields(self, authed_client):
360372
assert data["downloader"]["password"] == "********"
361373
# OpenAI api_key should be masked (it's an empty string but still masked)
362374
assert data["experimental_openai"]["api_key"] == "********"
375+
376+
377+
# ---------------------------------------------------------------------------
378+
# _restore_masked unit tests (#995)
379+
# ---------------------------------------------------------------------------
380+
381+
382+
class TestRestoreMasked:
383+
"""Issue #995: Masked passwords must not overwrite real credentials."""
384+
385+
def test_masked_password_restored(self):
386+
"""Masked password is replaced with the real stored value."""
387+
incoming = {"password": "********"}
388+
current = {"password": "real_secret"}
389+
_restore_masked(incoming, current)
390+
assert incoming["password"] == "real_secret"
391+
392+
def test_new_password_preserved(self):
393+
"""Non-masked password value is kept as-is."""
394+
incoming = {"password": "new_password"}
395+
current = {"password": "old_password"}
396+
_restore_masked(incoming, current)
397+
assert incoming["password"] == "new_password"
398+
399+
def test_nested_masked_password_restored(self):
400+
"""Masked password inside nested dict is restored."""
401+
incoming = {"downloader": {"host": "10.0.0.1", "password": "********"}}
402+
current = {"downloader": {"host": "10.0.0.1", "password": "adminadmin"}}
403+
_restore_masked(incoming, current)
404+
assert incoming["downloader"]["password"] == "adminadmin"
405+
406+
def test_nested_new_password_preserved(self):
407+
"""Non-masked password inside nested dict is kept."""
408+
incoming = {"downloader": {"password": "changed"}}
409+
current = {"downloader": {"password": "old"}}
410+
_restore_masked(incoming, current)
411+
assert incoming["downloader"]["password"] == "changed"
412+
413+
def test_multiple_sensitive_fields(self):
414+
"""All sensitive fields are handled independently."""
415+
incoming = {
416+
"downloader": {"password": "********"},
417+
"proxy": {"password": "new_proxy_pass"},
418+
"experimental_openai": {"api_key": "********"},
419+
}
420+
current = {
421+
"downloader": {"password": "qb_pass"},
422+
"proxy": {"password": "old_proxy_pass"},
423+
"experimental_openai": {"api_key": "sk-real-key"},
424+
}
425+
_restore_masked(incoming, current)
426+
assert incoming["downloader"]["password"] == "qb_pass"
427+
assert incoming["proxy"]["password"] == "new_proxy_pass"
428+
assert incoming["experimental_openai"]["api_key"] == "sk-real-key"
429+
430+
def test_non_sensitive_mask_value_untouched(self):
431+
"""A non-sensitive key with '********' value is not modified."""
432+
incoming = {"host": "********"}
433+
current = {"host": "10.0.0.1"}
434+
_restore_masked(incoming, current)
435+
assert incoming["host"] == "********"
436+
437+
def test_list_of_dicts_restored(self):
438+
"""Masked tokens inside list items are restored."""
439+
incoming = {
440+
"providers": [
441+
{"type": "telegram", "token": "********"},
442+
{"type": "bark", "token": "new-bark-token"},
443+
]
444+
}
445+
current = {
446+
"providers": [
447+
{"type": "telegram", "token": "real-tg-token"},
448+
{"type": "bark", "token": "old-bark-token"},
449+
]
450+
}
451+
_restore_masked(incoming, current)
452+
assert incoming["providers"][0]["token"] == "real-tg-token"
453+
assert incoming["providers"][1]["token"] == "new-bark-token"
454+
455+
def test_empty_dicts(self):
456+
"""Empty dicts don't cause errors."""
457+
_restore_masked({}, {})
458+
459+
def test_round_trip_preserves_credentials(self):
460+
"""Full round-trip: sanitize then restore recovers original values."""
461+
original = {
462+
"downloader": {"host": "10.0.0.1", "password": "secret123"},
463+
"experimental_openai": {"api_key": "sk-abc", "model": "gpt-4"},
464+
}
465+
sanitized = _sanitize_dict(original)
466+
assert sanitized["downloader"]["password"] == "********"
467+
assert sanitized["experimental_openai"]["api_key"] == "********"
468+
469+
_restore_masked(sanitized, original)
470+
assert sanitized["downloader"]["password"] == "secret123"
471+
assert sanitized["experimental_openai"]["api_key"] == "sk-abc"
472+
assert sanitized["downloader"]["host"] == "10.0.0.1"
473+
assert sanitized["experimental_openai"]["model"] == "gpt-4"
474+
475+
def test_update_config_preserves_password_when_masked(
476+
self, authed_client, mock_settings
477+
):
478+
"""PATCH /config/update must not overwrite a real password with '********'."""
479+
mock_settings.dict.return_value = {
480+
"program": {"rss_time": 900, "rename_time": 60, "webui_port": 7892},
481+
"downloader": {
482+
"type": "qbittorrent",
483+
"host": "192.168.1.1:8080",
484+
"username": "admin",
485+
"password": "realpassword",
486+
"path": "/downloads",
487+
"ssl": True,
488+
},
489+
"rss_parser": {"enable": True, "filter": [], "language": "zh"},
490+
"bangumi_manage": {
491+
"enable": True,
492+
"eps_complete": False,
493+
"rename_method": "pn",
494+
"group_tag": False,
495+
"remove_bad_torrent": False,
496+
},
497+
"log": {"debug_enable": False},
498+
"proxy": {
499+
"enable": False,
500+
"type": "http",
501+
"host": "",
502+
"port": 0,
503+
"username": "",
504+
"password": "",
505+
},
506+
"notification": {
507+
"enable": False,
508+
"type": "telegram",
509+
"token": "",
510+
"chat_id": "",
511+
},
512+
"experimental_openai": {
513+
"enable": False,
514+
"api_key": "",
515+
"api_base": "https://api.openai.com/v1",
516+
"api_type": "openai",
517+
"api_version": "2023-05-15",
518+
"model": "gpt-3.5-turbo",
519+
"deployment_id": "",
520+
},
521+
}
522+
payload = {
523+
"program": {"rss_time": 900, "rename_time": 60, "webui_port": 7892},
524+
"downloader": {
525+
"type": "qbittorrent",
526+
"host": "192.168.1.1:8080",
527+
"username": "admin",
528+
"password": "********",
529+
"path": "/downloads",
530+
"ssl": False,
531+
},
532+
"rss_parser": {"enable": True, "filter": [], "language": "zh"},
533+
"bangumi_manage": {
534+
"enable": True,
535+
"eps_complete": False,
536+
"rename_method": "pn",
537+
"group_tag": False,
538+
"remove_bad_torrent": False,
539+
},
540+
"log": {"debug_enable": False},
541+
"proxy": {
542+
"enable": False,
543+
"type": "http",
544+
"host": "",
545+
"port": 0,
546+
"username": "",
547+
"password": "",
548+
},
549+
"notification": {
550+
"enable": False,
551+
"type": "telegram",
552+
"token": "",
553+
"chat_id": "",
554+
},
555+
"experimental_openai": {
556+
"enable": False,
557+
"api_key": "",
558+
"api_base": "https://api.openai.com/v1",
559+
"api_type": "openai",
560+
"api_version": "2023-05-15",
561+
"model": "gpt-3.5-turbo",
562+
"deployment_id": "",
563+
},
564+
}
565+
with patch("module.api.config.settings", mock_settings):
566+
response = authed_client.patch("/api/v1/config/update", json=payload)
567+
568+
assert response.status_code == 200
569+
saved = mock_settings.save.call_args[1]["config_dict"]
570+
assert saved["downloader"]["password"] == "realpassword"
571+
assert saved["downloader"]["ssl"] is False

0 commit comments

Comments
 (0)