From 7caed9efa7764fd1a21f4750bafc1ba0a5a1969c Mon Sep 17 00:00:00 2001 From: borealisalastor Date: Sat, 7 Nov 2026 11:29:02 +0800 Subject: [PATCH 1/9] fix(auth): store passwords with bcrypt instead of unsalted MD5 #1432 Storage moves to bcrypt in the shared helpers; legacy unsalted MD5 digests stay verifiable only for rows created before the migration and are rewritten on the next successful login. --- backend/common/core/security.py | 65 ++++++++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/backend/common/core/security.py b/backend/common/core/security.py index 3b8167749..aa56801e5 100644 --- a/backend/common/core/security.py +++ b/backend/common/core/security.py @@ -1,9 +1,12 @@ +import hashlib +import hmac +import re from datetime import datetime, timedelta, timezone from typing import Any import jwt from passlib.context import CryptContext -import hashlib + from common.core.config import settings pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") @@ -11,6 +14,11 @@ ALGORITHM = "HS256" +# Issue #1432: passwords used to be stored as unsalted MD5 digests. Rows that +# predate the bcrypt migration still carry one of these; they are accepted for +# verification only and are rewritten as bcrypt on the next successful login. +LEGACY_MD5_PWD_PATTERN = re.compile(r"^[0-9a-fA-F]{32}$") + def create_access_token(data: dict | Any, expires_delta: timedelta) -> str: to_encode = data.copy() @@ -21,24 +29,69 @@ def create_access_token(data: dict | Any, expires_delta: timedelta) -> str: return encoded_jwt +def get_password_hash(password: str) -> str: + """Hash a password for storage: bcrypt, never an unsalted MD5 digest (issue #1432).""" + return pwd_context.hash(password) + + +def is_legacy_md5_pwd(hashed_password: str) -> bool: + """True when a stored value is a legacy unsalted MD5 digest instead of a bcrypt hash.""" + return ( + bool(hashed_password) + and LEGACY_MD5_PWD_PATTERN.fullmatch(hashed_password) is not None + ) + + def verify_password(plain_password: str, hashed_password: str) -> bool: - return pwd_context.verify(plain_password, hashed_password) + """Verify a password against a stored hash. + bcrypt hashes are checked with passlib. Legacy unsalted MD5 digests stay + accepted so deployments created before issue #1432 was fixed can still log + in; the caller that owns the session rewrites them with bcrypt right + afterwards (see `apps.system.crud.user.authenticate`). + """ + if not plain_password or not hashed_password: + return False + if is_legacy_md5_pwd(hashed_password): + return hmac.compare_digest(md5pwd(plain_password), hashed_password.lower()) + try: + return pwd_context.verify(plain_password, hashed_password) + except (ValueError, TypeError): + return False -def get_password_hash(password: str) -> str: - return pwd_context.hash(password) def md5pwd(password: str) -> str: + """Legacy unsalted MD5 digest (issue #1432). + + Kept only so rows stored before the migration stay verifiable and can be + upgraded. Never use it to store a new or changed password. + """ m = hashlib.md5() m.update(password.encode("utf-8")) return m.hexdigest() + def verify_md5pwd(plain_password: str, md5_password: str) -> bool: - return md5pwd(plain_password) == md5_password + """Legacy-only verification against an unsalted MD5 digest (issue #1432).""" + if not plain_password or not md5_password: + return False + return hmac.compare_digest(md5pwd(plain_password), md5_password.lower()) + def default_pwd() -> str: return settings.DEFAULT_PWD + +def default_pwd_hash() -> str: + """bcrypt hash of the configured default password, used when resetting a user (issue #1432).""" + return get_password_hash(default_pwd()) + + def default_md5_pwd() -> str: + """Legacy digest of the default password (issue #1432). + + Kept so existing imports keep resolving; new call sites must use + `default_pwd_hash` instead. + """ pwd = default_pwd() - return md5pwd(pwd) \ No newline at end of file + return md5pwd(pwd) From 2937b11756fb66b242e32cf3339583f520f12a38 Mon Sep 17 00:00:00 2001 From: borealisalastor Date: Sat, 7 Nov 2026 11:34:47 +0800 Subject: [PATCH 2/9] fix(auth): verify legacy password digests and upgrade them on login authenticate() verifies through the shared helper and rewrites a verified legacy digest with bcrypt through the request-scoped session. --- backend/apps/system/crud/user.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/backend/apps/system/crud/user.py b/backend/apps/system/crud/user.py index 5f9928b1b..6726d3550 100644 --- a/backend/apps/system/crud/user.py +++ b/backend/apps/system/crud/user.py @@ -6,7 +6,7 @@ from apps.system.schemas.auth import CacheName, CacheNamespace from apps.system.schemas.system_schema import EMAIL_REGEX, PWD_REGEX, BaseUserDTO, UserInfoDTO, UserWs from common.core.deps import SessionDep -from common.core.security import verify_md5pwd +from common.core.security import get_password_hash, is_legacy_md5_pwd, verify_password from common.core.sqlbot_cache import cache, clear_cache from common.utils.locale import I18n, I18nHelper from common.utils.utils import SQLBotLogUtil @@ -45,11 +45,26 @@ def authenticate(*, session: Session, account: str, password: str) -> BaseUserDT db_user = get_user_by_account(session=session, account=account) if not db_user: return None - if not verify_md5pwd(password, db_user.password): + if not verify_password(password, db_user.password): return None + upgrade_legacy_pwd_hash(session=session, user_id=db_user.id, password=password) return db_user +def upgrade_legacy_pwd_hash(*, session: Session, user_id: int, password: str) -> None: + """Rewrite a just-verified legacy MD5 hash with bcrypt (issue #1432). + + Only reached after `verify_password` accepted `password`, so the new hash is + derived from the same plaintext. The request-scoped session commits at the + end of the call (common.core.db.get_session). + """ + db_user: UserModel = get_db_user(session=session, user_id=user_id) + if not db_user or not is_legacy_md5_pwd(db_user.password): + return + db_user.password = get_password_hash(password) + session.add(db_user) + + def user_ws_list(session: Session, uid: int, trans: Optional[I18n | I18nHelper] = None) -> list[UserWs]: if uid == 1: stmt = select(WorkspaceModel.id, WorkspaceModel.name).order_by(WorkspaceModel.name, WorkspaceModel.create_time) From cc92d203acab40e3767dd2900ca315938e5ec5cb Mon Sep 17 00:00:00 2001 From: borealisalastor Date: Sat, 7 Nov 2026 11:36:27 +0800 Subject: [PATCH 3/9] fix(auth): hash login and self-service password changes with bcrypt The login flow verifies through the shared helper and the change-pwd endpoint stores a bcrypt hash. --- backend/apps/system/api/login.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/apps/system/api/login.py b/backend/apps/system/api/login.py index 6a979a23c..bac770a75 100644 --- a/backend/apps/system/api/login.py +++ b/backend/apps/system/api/login.py @@ -6,7 +6,7 @@ from common.core.deps import SessionDep, Trans from common.utils.crypto import sqlbot_decrypt from ..crud.user import authenticate, check_pwd_format, clean_user_cache, get_db_user -from common.core.security import create_access_token, default_pwd, md5pwd, verify_md5pwd +from common.core.security import create_access_token, default_pwd, get_password_hash, verify_password from datetime import timedelta from common.core.config import settings from common.core.schemas import Token @@ -44,7 +44,7 @@ async def local_login( if ( user.id != 1 and await initial_pwd_disabled(session) - and verify_md5pwd(default_pwd(), user.password) + and verify_password(default_pwd(), user.password) ): return Token(access_token='', need_change_pwd=True) access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) @@ -75,7 +75,7 @@ async def login_change_pwd(session: SessionDep, trans: Trans, editor: LoginPwdEd if await initial_pwd_disabled(session) and new_pwd == default_pwd(): raise HTTPException(status_code=400, detail=trans('i18n_login.new_pwd_is_initial')) db_user = get_db_user(session=session, user_id=user.id) - db_user.password = md5pwd(new_pwd) + db_user.password = get_password_hash(new_pwd) session.add(db_user) await clean_user_cache(user.id) return db_user @@ -84,4 +84,4 @@ async def login_change_pwd(session: SessionDep, trans: Trans, editor: LoginPwdEd async def logout(session: SessionDep, request: Request, dto: LogoutSchema): if dto.origin != 0: return await xpack_logout(session, request, dto) - return None \ No newline at end of file + return None From fee57b6e978d368d6f1e3e8baf939ea0989f3af1 Mon Sep 17 00:00:00 2001 From: borealisalastor Date: Sat, 7 Nov 2026 11:38:42 +0800 Subject: [PATCH 4/9] fix(auth): default new users to a bcrypt password hash The model default factory now returns a bcrypt hash of the configured default password. --- backend/apps/system/models/user.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/apps/system/models/user.py b/backend/apps/system/models/user.py index 0004f057e..8e64d70fd 100644 --- a/backend/apps/system/models/user.py +++ b/backend/apps/system/models/user.py @@ -5,7 +5,7 @@ from sqlmodel import SQLModel, Field from common.core.models import SnowflakeBase -from common.core.security import default_md5_pwd +from common.core.security import default_pwd_hash from common.utils.time import get_timestamp @@ -13,7 +13,7 @@ class BaseUserPO(SQLModel): account: str = Field(max_length=255, unique=True) oid: int = Field(nullable=False, sa_type=BigInteger(), default=0) name: str = Field(max_length=255, unique=True) - password: str = Field(default_factory=default_md5_pwd, max_length=255) + password: str = Field(default_factory=default_pwd_hash, max_length=255) email: str = Field(max_length=255) status: int = Field(default=0, nullable=False) origin: int = Field(nullable=False, default=0) From b2243eeb96f743018cf653d753f6323ed8405c93 Mon Sep 17 00:00:00 2001 From: borealisalastor Date: Sat, 7 Nov 2026 11:40:26 +0800 Subject: [PATCH 5/9] fix(auth): store admin reset and profile password changes with bcrypt Resetting a user writes the bcrypt default hash and the self-service update stores bcrypt instead of MD5. --- backend/apps/system/api/user.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/apps/system/api/user.py b/backend/apps/system/api/user.py index 9fb06fefe..9f1c36d6e 100644 --- a/backend/apps/system/api/user.py +++ b/backend/apps/system/api/user.py @@ -15,7 +15,7 @@ from common.core.deps import CurrentUser, SessionDep, Trans from common.core.pagination import Paginator from common.core.schemas import PaginatedResponse, PaginationParams -from common.core.security import default_md5_pwd, md5pwd, verify_md5pwd +from common.core.security import default_pwd_hash, get_password_hash, verify_password from common.core.sqlbot_cache import clear_cache from common.core.config import settings from apps.swagger.i18n import PLACEHOLDER_PREFIX @@ -386,7 +386,7 @@ async def pwdReset(session: SessionDep, current_user: CurrentUser, trans: Trans, if not current_user.isAdmin: raise Exception(trans('i18n_permission.no_permission', url = " patch[/user/pwd/id],", msg = trans('i18n_permission.only_admin'))) db_user: UserModel = get_db_user(session=session, user_id=id) - db_user.password = default_md5_pwd() + db_user.password = default_pwd_hash() session.add(db_user) @router.put("/pwd", summary=f"{PLACEHOLDER_PREFIX}update_pwd", description=f"{PLACEHOLDER_PREFIX}update_pwd") @@ -397,11 +397,11 @@ async def pwdUpdate(session: SessionDep, current_user: CurrentUser, trans: Trans if not check_pwd_format(new_pwd): raise Exception(trans('i18n_format_invalid', key = trans('i18n_user.password'))) db_user: UserModel = get_db_user(session=session, user_id=current_user.id) - if not verify_md5pwd(editor.pwd, db_user.password): + if not verify_password(editor.pwd, db_user.password): raise Exception(trans('i18n_error', key = trans('i18n_user.password'))) if await initial_pwd_disabled(session) and new_pwd == settings.DEFAULT_PWD: raise HTTPException(status_code=400, detail=trans('i18n_login.new_pwd_is_initial')) - db_user.password = md5pwd(new_pwd) + db_user.password = get_password_hash(new_pwd) session.add(db_user) return db_user From c48cc99f897fb6709bfeff5cdd828840a0faf45e Mon Sep 17 00:00:00 2001 From: borealisalastor Date: Sat, 7 Nov 2026 11:44:43 +0800 Subject: [PATCH 6/9] chore(tests): follow the bcrypt helpers in the initial-password guard The guard keeps its assertions but stubs get_password_hash and verify_password instead of the retired MD5 helpers. --- backend/tests/test_initial_password_policy.py | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/backend/tests/test_initial_password_policy.py b/backend/tests/test_initial_password_policy.py index 8f4fad7fe..0f9ccf062 100644 --- a/backend/tests/test_initial_password_policy.py +++ b/backend/tests/test_initial_password_policy.py @@ -2,11 +2,15 @@ Extract actual functions to avoid importing DB/xpack startup. These tests do not claim coverage of authentication middleware, decorators or real transactions. + +Guard update (issue #1432): stored passwords moved from unsalted MD5 digests to +bcrypt, so the stubs now follow `get_password_hash` / `verify_password`. The +assertions still pin what this guard always protected: password state only +changes when the old credential and the new format are both accepted. """ import ast import asyncio -import hashlib import json from pathlib import Path from types import SimpleNamespace @@ -16,6 +20,7 @@ from fastapi import HTTPException ROOT = Path(__file__).resolve().parents[2] +BCRYPT_STUB_PREFIX = "bcrypt-stub$" def load_function(path, name, namespace): @@ -40,13 +45,14 @@ def load_function(path, name, namespace): return namespace[name] -def md5(value): - return hashlib.md5(value.encode()).hexdigest() +def bcrypt_stub(value): + """Deterministic stand-in for `common.core.security.get_password_hash`.""" + return f"{BCRYPT_STUB_PREFIX}{value}" @pytest.fixture def context(): - user = SimpleNamespace(id=2, password=md5("OldPassword42!")) + user = SimpleNamespace(id=2, password=bcrypt_stub("OldPassword42!")) session = Mock() args = AsyncMock( return_value=[SimpleNamespace(pkey="login.initial_pwd_disabled", pval="true")] @@ -55,8 +61,8 @@ def context(): "get_group_args": args, "HTTPException": HTTPException, "get_db_user": Mock(return_value=user), - "md5pwd": md5, - "verify_md5pwd": lambda value, hashed: md5(value) == hashed, + "get_password_hash": bcrypt_stub, + "verify_password": lambda value, hashed: hashed == bcrypt_stub(value), "default_pwd": lambda: "InitialPassword42!", "settings": SimpleNamespace(DEFAULT_PWD="InitialPassword42!"), } @@ -115,14 +121,14 @@ def test_disabled_off_allows_initial_password(context, value): user, session, args, _, _ = context args.return_value = [SimpleNamespace(pkey="login.initial_pwd_disabled", pval=value)] update(context) - assert user.password == md5("InitialPassword42!") + assert user.password == bcrypt_stub("InitialPassword42!") session.add.assert_called_once_with(user) def test_enabled_allows_other_password(context): user, session, _, _, _ = context update(context, new="DifferentPassword42!") - assert user.password == md5("DifferentPassword42!") + assert user.password == bcrypt_stub("DifferentPassword42!") session.add.assert_called_once_with(user) From 5302288a6d7058fe1801528f2f66af8b7bed4375 Mon Sep 17 00:00:00 2001 From: borealisalastor Date: Sat, 7 Nov 2026 11:46:21 +0800 Subject: [PATCH 7/9] chore(tests): seed MCP integration users with bcrypt hashes The MCP integration fixture creates its user through get_password_hash instead of md5pwd. --- backend/tests/test_mcp_parameters_integration.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/tests/test_mcp_parameters_integration.py b/backend/tests/test_mcp_parameters_integration.py index c9e56bb4b..9986f5819 100644 --- a/backend/tests/test_mcp_parameters_integration.py +++ b/backend/tests/test_mcp_parameters_integration.py @@ -31,7 +31,7 @@ from apps.system.models.system_model import AiModelDetail, UserWsModel from apps.system.models.user import UserModel -from common.core.security import md5pwd +from common.core.security import get_password_hash table_environment = table_contracts.table_environment @@ -139,7 +139,7 @@ def test_mcp_network_task_uses_parameter_saved_by_another_api_process( name="MCP test", oid=1, email="mcp@example.invalid", - password=md5pwd(password), + password=get_password_hash(password), status=1, ) ) From 126c841ea7fbbe38b8cfe0577c011b86c7027283 Mon Sep 17 00:00:00 2001 From: borealisalastor Date: Sat, 7 Nov 2026 11:48:04 +0800 Subject: [PATCH 8/9] test(auth): add a guard for the password hashing migration Covers bcrypt storage, legacy digest compatibility, the login upgrade path and the repository-wide ban on MD5 password writes. --- backend/tests/test_password_hash_migration.py | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 backend/tests/test_password_hash_migration.py diff --git a/backend/tests/test_password_hash_migration.py b/backend/tests/test_password_hash_migration.py new file mode 100644 index 000000000..c1ef43709 --- /dev/null +++ b/backend/tests/test_password_hash_migration.py @@ -0,0 +1,211 @@ +"""Regression coverage for the password hashing migration (issue #1432). + +Passwords used to be stored as unsalted MD5 digests, so a single database dump +allowed offline cracking and equal passwords were equal in the table. Storage +now goes through bcrypt (`common.core.security.get_password_hash`), while +verification keeps accepting a legacy digest so an existing deployment can still +log in once and gets upgraded during that login. + +The tests below import the real helpers (no database, no xpack). `authenticate` +is extracted from source with the same AST helper the sibling guard +(`test_initial_password_policy.py`) uses, so the login upgrade path is covered +without importing DB/xpack startup. +""" + +import ast +import hashlib +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock + +from common.core.security import ( + default_pwd, + default_pwd_hash, + get_password_hash, + is_legacy_md5_pwd, + verify_md5pwd, + verify_password, +) + +ROOT = Path(__file__).resolve().parents[2] +USER_CRUD = "backend/apps/system/crud/user.py" +SECURITY_MODULE = ROOT / "backend" / "common" / "core" / "security.py" + +PASSWORD = "Str0ng-Pass!23" +LEGACY_DIGEST = hashlib.md5(PASSWORD.encode("utf-8")).hexdigest() +# Only these helpers may hash a password with MD5, and only for the legacy +# verification path; `verify_md5pwd` / `is_legacy_md5_pwd` are lookups, not writes. +LEGACY_STORAGE_HELPERS = {"md5pwd", "default_md5_pwd"} + + +def load_function(path, name, namespace): + tree = ast.parse((ROOT / path).read_text()) + node = next( + n + for n in tree.body + if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and n.name == name + ) + node.decorator_list = [] + node.returns = None + # `authenticate` takes keyword-only arguments, whose annotations would be + # evaluated below with types this offline namespace does not provide. + for arg in node.args.args + node.args.posonlyargs + node.args.kwonlyargs: + arg.annotation = None + exec( + compile( + ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])), + str(path), + "exec", + ), + namespace, + ) + return namespace[name] + + +def _login_context(stored_hash): + """Run `authenticate` with the DB and DTO boundaries replaced.""" + + class FakeSession: + def __init__(self): + self.added = [] + + def add(self, obj): + self.added.append(obj) + + session = FakeSession() + stored_user = None + if stored_hash is not None: + stored_user = SimpleNamespace(id=7, account="alice", password=stored_hash) + namespace = { + "get_user_by_account": Mock(return_value=stored_user), + "get_db_user": Mock(return_value=stored_user), + "verify_password": verify_password, + "get_password_hash": get_password_hash, + "is_legacy_md5_pwd": is_legacy_md5_pwd, + "BaseUserDTO": SimpleNamespace, + "UserModel": SimpleNamespace, + } + load_function(USER_CRUD, "authenticate", namespace) + load_function(USER_CRUD, "upgrade_legacy_pwd_hash", namespace) + return session, stored_user, namespace["authenticate"] + + +# --- storage: new and changed passwords are bcrypt --- + + +def test_stored_hashes_are_bcrypt_not_md5(): + hashed = get_password_hash(PASSWORD) + assert hashed.startswith("$2") + assert not is_legacy_md5_pwd(hashed) + assert len(hashed) <= 255 # sys_user.password column + + +def test_bcrypt_hash_only_verifies_the_original_password(): + hashed = get_password_hash(PASSWORD) + assert verify_password(PASSWORD, hashed) + assert not verify_password("Wrong-Pass!23", hashed) + + +def test_default_password_reset_uses_bcrypt(): + hashed = default_pwd_hash() + assert hashed.startswith("$2") + assert not is_legacy_md5_pwd(hashed) + assert verify_password(default_pwd(), hashed) + + +# --- legacy digests: accepted for verification, never rewritten to MD5 --- + + +def test_legacy_md5_digest_still_verifies_a_pre_migration_row(): + assert is_legacy_md5_pwd(LEGACY_DIGEST) + assert verify_password(PASSWORD, LEGACY_DIGEST) + assert not verify_password("Wrong-Pass!23", LEGACY_DIGEST) + + +def test_legacy_helper_matches_the_compatibility_path(): + assert verify_md5pwd(PASSWORD, LEGACY_DIGEST) + assert not verify_md5pwd("Wrong-Pass!23", LEGACY_DIGEST) + + +def test_verification_tolerates_empty_and_malformed_stored_values(): + assert not verify_password("", get_password_hash(PASSWORD)) + assert not verify_password(PASSWORD, "") + assert not verify_password(PASSWORD, "not-a-hash") + assert not verify_md5pwd("", LEGACY_DIGEST) + assert not is_legacy_md5_pwd("") + + +# --- login path: a successful legacy login is upgraded to bcrypt --- + + +def test_legacy_login_is_migrated_to_bcrypt(): + session, user, authenticate = _login_context(LEGACY_DIGEST) + assert authenticate(session=session, account="alice", password=PASSWORD) is user + assert not is_legacy_md5_pwd(user.password) + assert user.password.startswith("$2") + assert verify_password(PASSWORD, user.password) + assert session.added == [user] + + +def test_bcrypt_login_does_not_rewrite_the_hash(): + hashed = get_password_hash(PASSWORD) + session, user, authenticate = _login_context(hashed) + assert authenticate(session=session, account="alice", password=PASSWORD) is user + assert user.password == hashed + assert session.added == [] + + +def test_failed_login_never_upgrades_a_legacy_digest(): + session, user, authenticate = _login_context(LEGACY_DIGEST) + assert ( + authenticate(session=session, account="alice", password="Wrong-Pass!23") is None + ) + assert user.password == LEGACY_DIGEST + assert session.added == [] + + +def test_unknown_account_is_rejected_without_writing(): + session, _, authenticate = _login_context(None) + assert authenticate(session=session, account="alice", password=PASSWORD) is None + assert session.added == [] + + +# --- repository guard: production code must not store MD5 password hashes --- + + +def _legacy_storage_lines(path): + tree = ast.parse(path.read_text()) + for node in ast.walk(tree): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id in LEGACY_STORAGE_HELPERS + ): + yield f"{path.relative_to(ROOT)}:{node.lineno}" + + +def test_no_production_module_stores_passwords_with_md5(): + offenders = [] + for package in ("apps", "common"): + for path in sorted((ROOT / "backend" / package).rglob("*.py")): + if path == SECURITY_MODULE: + continue # holds the legacy helpers themselves + offenders.extend(_legacy_storage_lines(path)) + assert not offenders, f"unsalted MD5 password hashing is back: {offenders}" + + +def test_the_storage_helper_never_calls_a_legacy_helper(): + tree = ast.parse(SECURITY_MODULE.read_text()) + storage = next( + node + for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name == "get_password_hash" + ) + called = { + node.func.id + for node in ast.walk(storage) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + } + assert ( + not called & LEGACY_STORAGE_HELPERS + ), f"storage path calls {called & LEGACY_STORAGE_HELPERS}" From dbaecec711f7b9240a8f38bcd597829cfccaf1e7 Mon Sep 17 00:00:00 2001 From: borealisalastor Date: Sat, 7 Nov 2026 11:49:47 +0800 Subject: [PATCH 9/9] docs(agents): document bcrypt-only password storage The security guide states that only bcrypt stores passwords and what the legacy MD5 helpers are still allowed to do. --- docs/agents/security.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/agents/security.md b/docs/agents/security.md index 3fbcaacd4..5b31fb96b 100644 --- a/docs/agents/security.md +++ b/docs/agents/security.md @@ -51,6 +51,7 @@ - 修改 `backend/main.py` 的中间件顺序时,验证实际请求执行顺序(不能只看注册顺序);`HostValidationMiddleware` 必须在认证/业务使用 Host 前拒绝非法值,并回归预检与正常请求。 - Host 只允许合法域名 / IPv4 / IPv6 形态,不能包含 `/`、`@` 或空白。 - 认证和助手 token 头由后端中间件与前端 request interceptor 处理;不要新增手工传递或复制 token 的路径。 +- 口令只以 bcrypt 存储(`common.core.security.get_password_hash`);`md5pwd` / `verify_md5pwd` / `default_md5_pwd` 只用于校验并升级迁移前遗留的无盐 MD5 摘要,登录成功后必须改写为 bcrypt,禁止用它们写入新口令。 - 页面嵌入协议必须保持: - 仅 `AssistantModel.type == 4` 可用 embedded 协议; - 先提取目标助手,再用 `app_secret` 验证签名;