From eb2db8f507e3198e0ae45b976bdd90eab2f7d1ed Mon Sep 17 00:00:00 2001 From: vastsa Date: Mon, 14 Sep 2026 21:52:23 +0800 Subject: [PATCH] fix: don't let a legacy background value block settings saves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #514 began validating `background` as an http(s) URL against the *merged* config, so a value stored by an older release (a relative path, or one containing a space/parenthesis — all legal before) made every subsequent settings save return 400, including saves that never touched `background`. Existing deployments could not change any setting at all. Validate only values that actually change: an untouched legacy value is kept as-is (it is still html-escaped on render), while any edit must pass validation. Regression tests cover both directions and fail without this change. Also treat OverflowError from hashlib.scrypt as a non-matching hash so a hand-crafted n/r/p cannot disturb the login path on runtimes that raise it. --- apps/admin/services.py | 14 ++++-- core/utils.py | 4 +- tests/test_background_url_validation.py | 58 +++++++++++++++++++++++++ tests/test_password_hashing.py | 5 +++ 4 files changed, 76 insertions(+), 5 deletions(-) diff --git a/apps/admin/services.py b/apps/admin/services.py index 97da8f847..ab7a97e11 100644 --- a/apps/admin/services.py +++ b/apps/admin/services.py @@ -1567,10 +1567,16 @@ async def update_config(self, data: dict): detail="storage_limit 不能小于 0", ) - try: - validate_background_url(next_config.get("background", "")) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) + # 只校验"发生变化"的值:升级前存入的旧格式 background(相对路径、含空格 + # 或括号)在旧版本是合法的,若每次保存都重新校验,存量部署会连无关设置项 + # 都保存不了(一律 400)。渲染侧仍然 html 转义,而任何修改都必须通过校验。 + current_background = str(settings.background or "") + candidate_background = str(next_config.get("background") or "") + if candidate_background != current_background: + try: + validate_background_url(candidate_background) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) if admin_password_changed: next_config["jwt_secret"] = generate_jwt_secret() diff --git a/core/utils.py b/core/utils.py index 42f0cbee9..da4461bf8 100644 --- a/core/utils.py +++ b/core/utils.py @@ -174,7 +174,9 @@ def verify_password(password: str, hashed: str) -> bool: p=int(p), maxmem=_SCRYPT_MAXMEM, ).hex() - except (ValueError, TypeError): + # 默认解释器对超范围的 n/r/p 抛 TypeError/ValueError;部分构建会抛 + # OverflowError,一并视为校验失败,避免坏掉的存量哈希影响登录路径。 + except (ValueError, TypeError, OverflowError): return False return hmac.compare_digest(password_hash, stored_hash) diff --git a/tests/test_background_url_validation.py b/tests/test_background_url_validation.py index fb08114e5..634a41110 100644 --- a/tests/test_background_url_validation.py +++ b/tests/test_background_url_validation.py @@ -73,5 +73,63 @@ async def _scenario(self): await close_db() +class LegacyBackgroundUpgradeTests(SettingsOverrideMixin, unittest.TestCase): + """存量部署升级:旧版本合法、新校验不接受的值,不得阻塞设置保存。""" + + def test_unchanged_legacy_background_does_not_block_unrelated_save(self): + asyncio.run(self._unchanged_legacy_allows_save()) + + async def _unchanged_legacy_allows_save(self): + await init_memory_db() + try: + from apps.base.config import refresh_settings + from apps.base.models import KeyValue + + # 旧版本合法(相对路径 + 空格),新的 http(s) 校验会拒绝 + legacy = "/static/bg image.png" + await KeyValue.create( + key="settings", value={"background": legacy, "name": "old"} + ) + await refresh_settings(force=True) + + service = ConfigService() + # 修复前这里会 400,导致存量部署连无关设置项都保存不了 + await service.update_config({"name": "new"}) + + record = await KeyValue.filter(key="settings").first() + self.assertEqual(record.value.get("background"), legacy, "旧值应原样保留") + self.assertEqual(record.value.get("name"), "new") + finally: + await close_db() + + def test_changing_background_is_still_validated(self): + asyncio.run(self._change_still_validated()) + + async def _change_still_validated(self): + await init_memory_db() + try: + from fastapi import HTTPException + + from apps.base.config import refresh_settings + from apps.base.models import KeyValue + + await KeyValue.create(key="settings", value={"background": "/legacy/bg.png"}) + await refresh_settings(force=True) + + service = ConfigService() + # 修改为恶意值仍然被拒 + with self.assertRaises(HTTPException) as ctx: + await service.update_config( + {"background": "x') ;background:url(https://evil.com/)"} + ) + self.assertEqual(ctx.exception.status_code, 400) + # 修改为合法值可以通过 + await service.update_config({"background": "https://ok.example/bg.png"}) + record = await KeyValue.filter(key="settings").first() + self.assertEqual(record.value.get("background"), "https://ok.example/bg.png") + finally: + await close_db() + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_password_hashing.py b/tests/test_password_hashing.py index 07449279f..0ed0ce020 100644 --- a/tests/test_password_hashing.py +++ b/tests/test_password_hashing.py @@ -53,6 +53,11 @@ def test_malformed_hashes_are_rejected_not_crashing(self): self.assertFalse(verify_password("x", "scrypt$n$r$p$salt$zzz")) self.assertFalse(verify_password("x", "sha256$only-two")) self.assertFalse(verify_password("x", "")) + # 超范围的 n/r/p:解释器抛 TypeError/ValueError(部分构建抛 OverflowError), + # 都必须被吞掉并判为不匹配,而不是让登录及每个请求 500 + self.assertFalse( + verify_password("x", "scrypt$99999999999999999999999999$8$1$aa$bb") + ) class TransparentRehashTests(SettingsOverrideMixin, unittest.TestCase):