fix(Security): Host header injection auth bypass and forged admin embedded token (CNVD)

- TokenMiddleware/ResponseMiddleware/audit logger use scope['path']
  instead of request.url.path for whitelist checks: a crafted Host
  header (e.g. x/api/v1/mcp) was concatenated into Starlette's URL
  and made protected routes match the /mcp* whitelist entry
- validateEmbedded rejects admin accounts and non-type-4 apps, so a
  self-signed token with account=admin can no longer impersonate the
  administrator
- Add HostValidationMiddleware rejecting malformed Host headers at the
  outermost layer (defense in depth)
- Add regression tests for host validation, whitelist matching and
  source-level guards

Co-Authored-By: Claude <[email protected]>
This commit is contained in:
fit2cloud-chenyw
2026-08-31 11:05:05 +08:00
committed by fit2cloud-chenyw
co-authored by Claude
parent cc8a7546e9
commit bbfc4cc8f5
6 changed files with 311 additions and 6 deletions
+15 -4
View File
@@ -31,13 +31,17 @@ class TokenMiddleware(BaseHTTPMiddleware):
super().__init__(app)
async def dispatch(self, request, call_next):
if self.is_options(request) or whiteUtils.is_whitelisted(request.url.path):
# 使用 scope["path"](请求行真实路径)做白名单判断:
# request.url.path 会拼接未校验的 Host 头(Starlette URL(scope) 行为),
# 攻击者可通过伪造 Host: x/api/v1/mcp 将受保护接口伪装成白名单路径绕过认证
request_path = request.scope.get("path") or request.url.path
if self.is_options(request) or whiteUtils.is_whitelisted(request_path):
# 动态处理 /system/assistant/info/{id} 的 CORS 预检
if request.method == "OPTIONS":
origin = request.headers.get("origin", "")
if origin:
match = re.search(r'/system/assistant/info/(\d+)', request.url.path)
match = re.search(r'/system/assistant/info/(\d+)', request_path)
if match:
assistant_id = int(match.group(1))
with Session(engine) as session:
@@ -221,6 +225,9 @@ class TokenMiddleware(BaseHTTPMiddleware):
with Session(engine) as session:
assistant_info = await get_assistant_info(session=session, assistant_id=embeddedId)
assistant_info = AssistantModel.model_validate(assistant_info)
# embedded 协议(app_secret + account)仅适用于页面嵌入(type=4)应用
if assistant_info.type != 4:
return False, f"Invalid embedded app type!"
payload = jwt.decode(
param, assistant_info.app_secret, algorithms=[security.ALGORITHM]
)
@@ -232,7 +239,7 @@ class TokenMiddleware(BaseHTTPMiddleware):
message = trans('i18n_not_exist', msg = trans('i18n_user.account'))
raise Exception(message)
session_user = await get_user_info(session = session, user_id = session_user.id)
session_user = UserInfoDTO.model_validate(session_user)
if session_user.status != 1:
message = trans('i18n_login.user_disable', msg = trans('i18n_concat_admin'))
@@ -240,6 +247,10 @@ class TokenMiddleware(BaseHTTPMiddleware):
if not session_user.oid or session_user.oid == 0:
message = trans('i18n_login.no_associated_ws', msg = trans('i18n_concat_admin'))
raise Exception(message)
# 管理员账号不允许通过 embedded token 使用:app_secret 由集成方持有,
# 攻击者若取得任意应用 app_secret 即可伪造 account=admin 的管理员身份
if session_user.isAdmin:
return False, f"Admin account is not allowed for embedded token!"
if session_user.oid:
assistant_info.oid = int(session_user.oid)
return True, session_user, assistant_info
@@ -440,7 +440,7 @@ class SystemLogger:
module=config.module,
resource_id=str(resource_id),
request_method=request.method if request else None,
request_path=request.url.path if request else None,
request_path=(request.scope.get("path") or request.url.path) if request else None,
request_params=request_params,
create_time=datetime.now(),
remark=remark
+21
View File
@@ -0,0 +1,21 @@
import re
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
# 合法 Host:域名 / IPv4 / IPv6(含端口),排除 '/'、'@'、空白等非法字符。
# Starlette 的 URL(scope) 会把 Host 头原始值拼进 URL,Host 携带路径片段会污染
# request.url.path 等派生属性(历史上被用于绕过认证白名单),因此请求入口处直接拒绝。
_HOST_RE = re.compile(r'^[A-Za-z0-9.\-:\[\]]{1,255}$')
class HostValidationMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
host = request.headers.get("host")
if not host or not _HOST_RE.match(host):
return JSONResponse(
{"code": 400, "data": None, "msg": "invalid host header"},
status_code=400,
)
return await call_next(request)
+1 -1
View File
@@ -40,7 +40,7 @@ class ResponseMiddleware(BaseHTTPMiddleware):
path_pattern = '' if not route else route.path_format
if (isinstance(response, JSONResponse)
or request.url.path == f"{settings.CONTEXT_PATH}/openapi.json"
or (request.scope.get("path") or request.url.path) == f"{settings.CONTEXT_PATH}/openapi.json"
or path_pattern in direct_paths):
return response
if response.status_code != 200:
+3
View File
@@ -25,6 +25,7 @@ from apps.system.middleware.auth import TokenMiddleware
from apps.system.schemas.permission import RequestContextMiddleware
from common.audit.schemas.request_context import RequestContextMiddlewareCommon
from common.core.config import settings
from common.core.host_validation import HostValidationMiddleware
from common.core.response_middleware import ResponseMiddleware, exception_handler
from common.core.sqlbot_cache import init_sqlbot_cache
from common.utils.distributed_lock import SingleWorkerGuard
@@ -239,6 +240,8 @@ app.add_middleware(TokenMiddleware)
app.add_middleware(ResponseMiddleware)
app.add_middleware(RequestContextMiddleware)
app.add_middleware(RequestContextMiddlewareCommon)
# 最后注册即最外层:非法 Host 头(携带路径片段等)在进入任何业务逻辑前被拒绝
app.add_middleware(HostValidationMiddleware)
app.include_router(api_router, prefix=settings.API_V1_STR)
# Register exception handlers