Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions backend/src/module/api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
logger = logging.getLogger(__name__)

_SENSITIVE_KEYS = ("password", "api_key", "token", "secret")
_MASK = "********"


def _is_sensitive(key: str) -> bool:
return any(s in key.lower() for s in _SENSITIVE_KEYS)


def _sanitize_dict(d: dict) -> dict:
Expand All @@ -19,13 +24,36 @@ def _sanitize_dict(d: dict) -> dict:
for k, v in d.items():
if isinstance(v, dict):
result[k] = _sanitize_dict(v)
elif isinstance(v, str) and any(s in k.lower() for s in _SENSITIVE_KEYS):
result[k] = "********"
elif isinstance(v, list):
result[k] = [
_sanitize_dict(item) if isinstance(item, dict) else item for item in v
]
elif isinstance(v, str) and _is_sensitive(k):
result[k] = _MASK
else:
result[k] = v
return result


def _restore_masked(incoming: dict, current: dict) -> dict:
"""Replace masked sentinel values with real values from current config."""
for k, v in incoming.items():
if isinstance(v, dict) and isinstance(current.get(k), dict):
_restore_masked(v, current[k])
elif isinstance(v, list) and isinstance(current.get(k), list):
cur_list = current[k]
for i, item in enumerate(v):
if (
isinstance(item, dict)
and i < len(cur_list)
and isinstance(cur_list[i], dict)
):
_restore_masked(item, cur_list[i])
elif v == _MASK and _is_sensitive(k):
incoming[k] = current.get(k, v)
return incoming


@router.get("/get", dependencies=[Depends(get_current_user)])
async def get_config():
"""Return the current configuration with sensitive fields masked."""
Expand All @@ -38,7 +66,8 @@ async def get_config():
async def update_config(config: Config):
"""Persist and reload configuration from the supplied payload."""
try:
settings.save(config_dict=config.dict())
config_dict = _restore_masked(config.dict(), settings.dict())
settings.save(config_dict=config_dict)
settings.load()
# update_rss()
logger.info("Config updated")
Expand Down
1 change: 0 additions & 1 deletion backend/src/module/api/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,6 @@ async def test_downloader(req: TestDownloaderRequest):

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

try:
async with httpx.AsyncClient(timeout=5.0) as client:
Expand Down
2 changes: 2 additions & 0 deletions backend/src/module/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from mcp.server.sse import SseServerTransport
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import Response
from starlette.routing import Mount, Route

from .resources import RESOURCE_TEMPLATES, RESOURCES, handle_resource
Expand Down Expand Up @@ -64,6 +65,7 @@ async def handle_sse(request: Request):
streams[1],
server.create_initialization_options(),
)
return Response()


def create_mcp_starlette_app() -> Starlette:
Expand Down
2 changes: 1 addition & 1 deletion backend/src/module/models/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ class RSSParser(BaseModel):
"""RSS feed parsing settings."""

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


Expand Down
247 changes: 228 additions & 19 deletions backend/src/test/test_api_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from fastapi.testclient import TestClient

from module.api import v1
from module.api.config import _sanitize_dict
from module.api.config import _sanitize_dict, _restore_masked
from module.models.config import Config
from module.security.api import get_current_user

Expand Down Expand Up @@ -306,24 +306,20 @@ def test_non_sensitive_keys_pass_through(self):

def test_nested_dict_recursed(self):
"""Nested dicts are processed recursively."""
result = _sanitize_dict({
"downloader": {
"host": "localhost",
"password": "secret",
result = _sanitize_dict(
{
"downloader": {
"host": "localhost",
"password": "secret",
}
}
})
)
assert result["downloader"]["host"] == "localhost"
assert result["downloader"]["password"] == "********"

def test_deeply_nested_dict(self):
"""Deeply nested sensitive keys are masked."""
result = _sanitize_dict({
"level1": {
"level2": {
"api_key": "deep-secret"
}
}
})
result = _sanitize_dict({"level1": {"level2": {"api_key": "deep-secret"}}})
assert result["level1"]["level2"]["api_key"] == "********"

def test_non_string_value_not_masked(self):
Expand All @@ -338,17 +334,33 @@ def test_empty_dict(self):

def test_mixed_sensitive_and_plain(self):
"""Mix of sensitive and plain keys handled correctly."""
result = _sanitize_dict({
"username": "admin",
"password": "secret",
"host": "10.0.0.1",
"token": "jwt-abc",
})
result = _sanitize_dict(
{
"username": "admin",
"password": "secret",
"host": "10.0.0.1",
"token": "jwt-abc",
}
)
assert result["username"] == "admin"
assert result["host"] == "10.0.0.1"
assert result["password"] == "********"
assert result["token"] == "********"

def test_sanitize_list_of_dicts(self):
"""Lists containing dicts are recursed into."""
result = _sanitize_dict(
{
"providers": [
{"type": "telegram", "token": "secret-token"},
{"type": "bark", "token": "another-secret"},
]
}
)
assert result["providers"][0]["token"] == "********"
assert result["providers"][1]["token"] == "********"
assert result["providers"][0]["type"] == "telegram"

def test_get_config_masks_sensitive_fields(self, authed_client):
"""GET /config/get response masks password and api_key fields."""
test_config = Config()
Expand All @@ -360,3 +372,200 @@ def test_get_config_masks_sensitive_fields(self, authed_client):
assert data["downloader"]["password"] == "********"
# OpenAI api_key should be masked (it's an empty string but still masked)
assert data["experimental_openai"]["api_key"] == "********"


# ---------------------------------------------------------------------------
# _restore_masked unit tests (#995)
# ---------------------------------------------------------------------------


class TestRestoreMasked:
"""Issue #995: Masked passwords must not overwrite real credentials."""

def test_masked_password_restored(self):
"""Masked password is replaced with the real stored value."""
incoming = {"password": "********"}
current = {"password": "real_secret"}
_restore_masked(incoming, current)
assert incoming["password"] == "real_secret"

def test_new_password_preserved(self):
"""Non-masked password value is kept as-is."""
incoming = {"password": "new_password"}
current = {"password": "old_password"}
_restore_masked(incoming, current)
assert incoming["password"] == "new_password"

def test_nested_masked_password_restored(self):
"""Masked password inside nested dict is restored."""
incoming = {"downloader": {"host": "10.0.0.1", "password": "********"}}
current = {"downloader": {"host": "10.0.0.1", "password": "adminadmin"}}
_restore_masked(incoming, current)
assert incoming["downloader"]["password"] == "adminadmin"

def test_nested_new_password_preserved(self):
"""Non-masked password inside nested dict is kept."""
incoming = {"downloader": {"password": "changed"}}
current = {"downloader": {"password": "old"}}
_restore_masked(incoming, current)
assert incoming["downloader"]["password"] == "changed"

def test_multiple_sensitive_fields(self):
"""All sensitive fields are handled independently."""
incoming = {
"downloader": {"password": "********"},
"proxy": {"password": "new_proxy_pass"},
"experimental_openai": {"api_key": "********"},
}
current = {
"downloader": {"password": "qb_pass"},
"proxy": {"password": "old_proxy_pass"},
"experimental_openai": {"api_key": "sk-real-key"},
}
_restore_masked(incoming, current)
assert incoming["downloader"]["password"] == "qb_pass"
assert incoming["proxy"]["password"] == "new_proxy_pass"
assert incoming["experimental_openai"]["api_key"] == "sk-real-key"

def test_non_sensitive_mask_value_untouched(self):
"""A non-sensitive key with '********' value is not modified."""
incoming = {"host": "********"}
current = {"host": "10.0.0.1"}
_restore_masked(incoming, current)
assert incoming["host"] == "********"

def test_list_of_dicts_restored(self):
"""Masked tokens inside list items are restored."""
incoming = {
"providers": [
{"type": "telegram", "token": "********"},
{"type": "bark", "token": "new-bark-token"},
]
}
current = {
"providers": [
{"type": "telegram", "token": "real-tg-token"},
{"type": "bark", "token": "old-bark-token"},
]
}
_restore_masked(incoming, current)
assert incoming["providers"][0]["token"] == "real-tg-token"
assert incoming["providers"][1]["token"] == "new-bark-token"

def test_empty_dicts(self):
"""Empty dicts don't cause errors."""
_restore_masked({}, {})

def test_round_trip_preserves_credentials(self):
"""Full round-trip: sanitize then restore recovers original values."""
original = {
"downloader": {"host": "10.0.0.1", "password": "secret123"},
"experimental_openai": {"api_key": "sk-abc", "model": "gpt-4"},
}
sanitized = _sanitize_dict(original)
assert sanitized["downloader"]["password"] == "********"
assert sanitized["experimental_openai"]["api_key"] == "********"

_restore_masked(sanitized, original)
assert sanitized["downloader"]["password"] == "secret123"
assert sanitized["experimental_openai"]["api_key"] == "sk-abc"
assert sanitized["downloader"]["host"] == "10.0.0.1"
assert sanitized["experimental_openai"]["model"] == "gpt-4"

def test_update_config_preserves_password_when_masked(
self, authed_client, mock_settings
):
"""PATCH /config/update must not overwrite a real password with '********'."""
mock_settings.dict.return_value = {
"program": {"rss_time": 900, "rename_time": 60, "webui_port": 7892},
"downloader": {
"type": "qbittorrent",
"host": "192.168.1.1:8080",
"username": "admin",
"password": "realpassword",
"path": "/downloads",
"ssl": True,
},
"rss_parser": {"enable": True, "filter": [], "language": "zh"},
"bangumi_manage": {
"enable": True,
"eps_complete": False,
"rename_method": "pn",
"group_tag": False,
"remove_bad_torrent": False,
},
"log": {"debug_enable": False},
"proxy": {
"enable": False,
"type": "http",
"host": "",
"port": 0,
"username": "",
"password": "",
},
"notification": {
"enable": False,
"type": "telegram",
"token": "",
"chat_id": "",
},
"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": "",
},
}
payload = {
"program": {"rss_time": 900, "rename_time": 60, "webui_port": 7892},
"downloader": {
"type": "qbittorrent",
"host": "192.168.1.1:8080",
"username": "admin",
"password": "********",
"path": "/downloads",
"ssl": False,
},
"rss_parser": {"enable": True, "filter": [], "language": "zh"},
"bangumi_manage": {
"enable": True,
"eps_complete": False,
"rename_method": "pn",
"group_tag": False,
"remove_bad_torrent": False,
},
"log": {"debug_enable": False},
"proxy": {
"enable": False,
"type": "http",
"host": "",
"port": 0,
"username": "",
"password": "",
},
"notification": {
"enable": False,
"type": "telegram",
"token": "",
"chat_id": "",
},
"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": "",
},
}
with patch("module.api.config.settings", mock_settings):
response = authed_client.patch("/api/v1/config/update", json=payload)

assert response.status_code == 200
saved = mock_settings.save.call_args[1]["config_dict"]
assert saved["downloader"]["password"] == "realpassword"
assert saved["downloader"]["ssl"] is False
Loading