262 lines
8.8 KiB
Python
262 lines
8.8 KiB
Python
|
|
"""API 安全依赖:认证、签名校验、防重放、限流、响应签名。"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import json
|
|||
|
|
import time
|
|||
|
|
from typing import TYPE_CHECKING, Any, Dict, Optional
|
|||
|
|
|
|||
|
|
from fastapi import Depends, HTTPException, Request, Response, status
|
|||
|
|
from fastapi.responses import JSONResponse
|
|||
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|||
|
|
|
|||
|
|
from app.config import get_settings
|
|||
|
|
from app.security import signing
|
|||
|
|
from app.security.crypto import constant_time_equals
|
|||
|
|
|
|||
|
|
_settings = get_settings()
|
|||
|
|
|
|||
|
|
# redis 是可选依赖——没装时限流和防重放降级为内存策略(_check_rate_limit / nonce 检查里已兜底)
|
|||
|
|
try:
|
|||
|
|
import redis as _redis # type: ignore
|
|||
|
|
except ModuleNotFoundError:
|
|||
|
|
_redis = None
|
|||
|
|
|
|||
|
|
if TYPE_CHECKING:
|
|||
|
|
import redis
|
|||
|
|
|
|||
|
|
_redis_client: Optional["redis.Redis"] = None
|
|||
|
|
|
|||
|
|
# 响应体原始字节暂存键,供响应签名中间件复用
|
|||
|
|
RAW_BODY_ATTR = "_meshsafe_raw_body"
|
|||
|
|
CLIENT_ATTR = "_meshsafe_client"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def get_redis():
|
|||
|
|
"""懒加载 Redis 客户端。未安装 redis 包或连接失败时由调用方兜底。"""
|
|||
|
|
global _redis_client
|
|||
|
|
if _redis is None:
|
|||
|
|
raise RuntimeError("redis 包未安装")
|
|||
|
|
if _redis_client is None:
|
|||
|
|
_redis_client = _redis.Redis.from_url(
|
|||
|
|
_settings.redis_url, decode_responses=True, socket_timeout=5
|
|||
|
|
)
|
|||
|
|
return _redis_client
|
|||
|
|
|
|||
|
|
|
|||
|
|
class ClientContext:
|
|||
|
|
"""已通过认证的调用方上下文。"""
|
|||
|
|
|
|||
|
|
def __init__(self, client_id: str, secret: str, role: str = "operator") -> None:
|
|||
|
|
self.client_id = client_id
|
|||
|
|
self.secret = secret
|
|||
|
|
self.role = role
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _lookup_client(client_id: str) -> Optional[ClientContext]:
|
|||
|
|
for item in _settings.parsed_api_clients:
|
|||
|
|
if constant_time_equals(item["client_id"], client_id):
|
|||
|
|
return ClientContext(item["client_id"], item["secret"])
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _check_rate_limit(client_id: str, path: str) -> None:
|
|||
|
|
"""基于 Redis 的滑动窗口限流,防止接口被暴力试探。"""
|
|||
|
|
try:
|
|||
|
|
r = get_redis()
|
|||
|
|
bucket = int(time.time() // 60)
|
|||
|
|
key = f"ms:rl:{client_id}:{path}:{bucket}"
|
|||
|
|
pipe = r.pipeline()
|
|||
|
|
pipe.incr(key)
|
|||
|
|
pipe.expire(key, 120)
|
|||
|
|
count = pipe.execute()[0]
|
|||
|
|
limit = 120 if "/scans" in path else 600
|
|||
|
|
if count > limit:
|
|||
|
|
raise HTTPException(
|
|||
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|||
|
|
detail="请求过于频繁,已触发限流",
|
|||
|
|
)
|
|||
|
|
except HTTPException:
|
|||
|
|
raise
|
|||
|
|
except Exception: # noqa: BLE001 Redis 不可用时不阻断主流程
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def authenticate(request: Request) -> ClientContext:
|
|||
|
|
"""认证与签名校验依赖。
|
|||
|
|
|
|||
|
|
依次执行:
|
|||
|
|
1. 解析并校验调用方标识
|
|||
|
|
2. 时间戳窗口校验(防重放)
|
|||
|
|
3. nonce 一次性校验
|
|||
|
|
4. 请求体哈希一致性校验
|
|||
|
|
5. HMAC 签名校验
|
|||
|
|
"""
|
|||
|
|
client_id = request.headers.get("X-MS-Client-Id", "")
|
|||
|
|
timestamp = request.headers.get("X-MS-Timestamp", "")
|
|||
|
|
nonce = request.headers.get("X-MS-Nonce", "")
|
|||
|
|
body_hash = request.headers.get("X-MS-Body-SHA256", "")
|
|||
|
|
signature = request.headers.get("X-MS-Signature", "")
|
|||
|
|
|
|||
|
|
if not client_id or not timestamp or not nonce or not signature:
|
|||
|
|
raise HTTPException(
|
|||
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|||
|
|
detail="缺少必要的认证请求头",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
ctx = _lookup_client(client_id)
|
|||
|
|
if ctx is None:
|
|||
|
|
# 不区分"不存在"与"密钥错误",避免枚举 client_id
|
|||
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="认证失败")
|
|||
|
|
|
|||
|
|
# 1) 时间戳窗口
|
|||
|
|
try:
|
|||
|
|
ts_int = int(timestamp)
|
|||
|
|
except ValueError:
|
|||
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="认证失败")
|
|||
|
|
skew = abs(int(time.time()) - ts_int)
|
|||
|
|
if skew > _settings.signature_window:
|
|||
|
|
raise HTTPException(
|
|||
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|||
|
|
detail=f"请求时间戳超出允许偏差({_settings.signature_window} 秒)",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 2) nonce 一次性
|
|||
|
|
try:
|
|||
|
|
r = get_redis()
|
|||
|
|
nonce_key = f"ms:nonce:{client_id}:{nonce}"
|
|||
|
|
if not r.set(nonce_key, "1", nx=True, ex=_settings.signature_window * 2):
|
|||
|
|
raise HTTPException(
|
|||
|
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="重复请求,已拒绝"
|
|||
|
|
)
|
|||
|
|
except HTTPException:
|
|||
|
|
raise
|
|||
|
|
except Exception: # noqa: BLE001
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
# 3) 读取请求体并校验哈希
|
|||
|
|
# 注意:multipart/form-data 请求体已被 FastAPI UploadFile 消费,不能再 await request.body()
|
|||
|
|
# 此时客户端已在 X-MS-Body-SHA256 里提供哈希,直接用即可
|
|||
|
|
body: bytes = b""
|
|||
|
|
ct = request.headers.get("content-type", "").lower()
|
|||
|
|
if not ct.startswith("multipart/"):
|
|||
|
|
try:
|
|||
|
|
body = await request.body()
|
|||
|
|
except RuntimeError:
|
|||
|
|
# stream 已被消费(比如中间件或 UploadFile),用空 body 兜底
|
|||
|
|
body = b""
|
|||
|
|
|
|||
|
|
skip_hash = ct.startswith("multipart/")
|
|||
|
|
if not signing.verify_request_signature(
|
|||
|
|
ctx.secret,
|
|||
|
|
ctx.client_id,
|
|||
|
|
request.method,
|
|||
|
|
request.url.path,
|
|||
|
|
dict(request.query_params),
|
|||
|
|
body,
|
|||
|
|
timestamp,
|
|||
|
|
nonce,
|
|||
|
|
body_hash,
|
|||
|
|
signature,
|
|||
|
|
skip_body_hash_check=skip_hash,
|
|||
|
|
):
|
|||
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="认证失败")
|
|||
|
|
|
|||
|
|
_check_rate_limit(ctx.client_id, request.url.path)
|
|||
|
|
|
|||
|
|
request.state.client = ctx
|
|||
|
|
return ctx
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def require_admin(ctx: ClientContext = Depends(authenticate)) -> ClientContext:
|
|||
|
|
"""要求调用方具备完整 findings 权限。"""
|
|||
|
|
if ctx.role != "operator":
|
|||
|
|
raise HTTPException(
|
|||
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|||
|
|
detail="当前凭证无权查看完整检测明细",
|
|||
|
|
)
|
|||
|
|
return ctx
|
|||
|
|
|
|||
|
|
|
|||
|
|
class ResponseSigningMiddleware(BaseHTTPMiddleware):
|
|||
|
|
"""为所有 API 响应添加签名头。
|
|||
|
|
|
|||
|
|
响应体在读取过程中暂存原始字节,签名基于原始字节计算,
|
|||
|
|
避免因 JSON 序列化差异导致客户端验签失败。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
async def dispatch(self, request: Request, call_next):
|
|||
|
|
response = await call_next(request)
|
|||
|
|
|
|||
|
|
if not _settings.sign_response:
|
|||
|
|
return response
|
|||
|
|
if request.url.path.startswith("/api/"):
|
|||
|
|
ctx = getattr(request.state, "client", None)
|
|||
|
|
if ctx is None:
|
|||
|
|
return response
|
|||
|
|
|
|||
|
|
body = getattr(response, RAW_BODY_ATTR, None)
|
|||
|
|
if body is None:
|
|||
|
|
body = b""
|
|||
|
|
if hasattr(response, "body_iterator"):
|
|||
|
|
chunks = []
|
|||
|
|
async for chunk in response.body_iterator:
|
|||
|
|
chunks.append(chunk if isinstance(chunk, bytes) else chunk.encode("utf-8"))
|
|||
|
|
body = b"".join(chunks)
|
|||
|
|
response = Response(
|
|||
|
|
content=body,
|
|||
|
|
status_code=response.status_code,
|
|||
|
|
headers=dict(response.headers),
|
|||
|
|
media_type=response.media_type,
|
|||
|
|
)
|
|||
|
|
# 删除旧 content-length(重建后会自动重新计算)
|
|||
|
|
try:
|
|||
|
|
del response.headers["content-length"]
|
|||
|
|
except KeyError:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
headers = signing.sign_response(ctx.secret, ctx.client_id, body)
|
|||
|
|
for key, value in headers.items():
|
|||
|
|
response.headers[key] = value
|
|||
|
|
return response
|
|||
|
|
|
|||
|
|
|
|||
|
|
def build_signed_json(ctx: ClientContext, payload: Dict[str, Any], status_code: int = 200) -> JSONResponse:
|
|||
|
|
"""构造带签名的 JSON 响应(供直接返回场景使用)。"""
|
|||
|
|
body = signing.canonical_json_bytes(payload)
|
|||
|
|
response = JSONResponse(content=payload, status_code=status_code)
|
|||
|
|
if _settings.sign_response:
|
|||
|
|
for key, value in signing.sign_response(ctx.secret, ctx.client_id, body).items():
|
|||
|
|
response.headers[key] = value
|
|||
|
|
return response
|
|||
|
|
|
|||
|
|
|
|||
|
|
def redact_for_role(payload: Dict[str, Any], role: str) -> Dict[str, Any]:
|
|||
|
|
"""按角色裁剪响应内容,降低信息泄露面。
|
|||
|
|
|
|||
|
|
``basic`` 角色只返回结论摘要,不返回引擎明细与提取文件路径,
|
|||
|
|
避免通过差异响应反推检测逻辑。
|
|||
|
|
"""
|
|||
|
|
if role == "operator":
|
|||
|
|
return payload
|
|||
|
|
allowed_top = {
|
|||
|
|
"task_id",
|
|||
|
|
"verdict",
|
|||
|
|
"score",
|
|||
|
|
"action",
|
|||
|
|
"reason",
|
|||
|
|
"status",
|
|||
|
|
"file",
|
|||
|
|
"meta",
|
|||
|
|
}
|
|||
|
|
trimmed = {k: v for k, v in payload.items() if k in allowed_top}
|
|||
|
|
file_info = trimmed.get("file")
|
|||
|
|
if isinstance(file_info, dict):
|
|||
|
|
trimmed["file"] = {
|
|||
|
|
"sha256": file_info.get("sha256"),
|
|||
|
|
"size": file_info.get("size"),
|
|||
|
|
"real_type": file_info.get("real_type"),
|
|||
|
|
}
|
|||
|
|
trimmed["detail_truncated"] = True
|
|||
|
|
return trimmed
|