feat(security): privacy hardening + data export (v0.2.1)

- CORS: default localhost-only origins (was *, allowed any webpage
  to read local health data cross-site); env override MI_FITNESS_CORS_ORIGINS
- Host header allowlist middleware on FastAPI and Flask (DNS-rebinding
  protection); auto-configured for non-loopback binds via CLI
- Move API key passTokens from SQLite plaintext to system keyring,
  with automatic migration on startup and cleanup for revoked keys
- Disable uvicorn access log by default (query params out of terminals)
- New GET /api/export: JSON/CSV export of local data (offline, Excel BOM)
- Add docs/PRIVACY.md: full data-flow audit, outbound endpoint inventory,
  threat model, self-verification steps
- 6 new tests (host allowlist, keyring roundtrip, CSV)
This commit is contained in:
binglua committed 2026-09-03 22:43:15 +08:00
1 parent 42c42b2c9e
commit a47a8470f8
10 files changed
+444 -13

No files matched your search

+136 -5
View File
@@ -26,7 +26,7 @@ import uuid
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Annotated
from typing import Annotated, Any
import httpx
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request
@@ -37,6 +37,14 @@ from pydantic import BaseModel
from mi_fitness_mcp.adapters.mi_fitness_cloud import MiFitnessCloudAdapter
from mi_fitness_mcp.auth import load_mi_fitness_token
from mi_fitness_mcp.config import load_config
from mi_fitness_mcp.security import (
cors_origins_from_env,
default_allowed_hosts,
delete_api_key_secret,
host_allowed,
load_api_key_secret,
store_api_key_secret,
)
from mi_fitness_mcp.services.query_service import QueryService
from mi_fitness_mcp.services.sync_service import SyncService
from mi_fitness_mcp.storage import Database
@@ -112,7 +120,20 @@ async def lifespan(app: FastAPI):
)
"""
)
# 迁移:历史版本把 passToken 明文存在该表,启动时移入系统 keyring 并清空列
legacy = conn.execute(
"SELECT key, pass_token FROM api_keys WHERE pass_token != ''"
).fetchall()
for row in legacy:
if store_api_key_secret(row["key"], row["pass_token"]):
conn.execute("UPDATE api_keys SET pass_token = '' WHERE key = ?", (row["key"],))
# 已吊销 Key 的 keyring 密钥一并清理(历史版本吊销时尚无 keyring 概念)
revoked_keys = [
r["key"] for r in conn.execute("SELECT key FROM api_keys WHERE revoked = 1").fetchall()
]
conn.commit()
for k in revoked_keys:
delete_api_key_secret(k)
default_context = None
if config.mode == "mi_fitness_cloud":
@@ -163,16 +184,53 @@ async def _gate(x_api_key: str | None = Header(default=None)) -> None:
app = FastAPI(
title="Mi Fitness API",
version="0.2.0",
version="0.2.1",
description="小米运动健康数据 REST API(多 Key 鉴权 + 扫码登录 + 本地 SQLite 缓存)",
lifespan=lifespan,
dependencies=[Depends(_gate)],
)
app.add_middleware(
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
CORSMiddleware,
allow_methods=["*"],
allow_headers=["*"],
**cors_origins_from_env(default_regex=r"^https?://(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$"),
)
class HostAllowlistMiddleware:
"""Host 头白名单(防 DNS 重绑定):拒绝非本机域名直连本地端口读取数据。"""
def __init__(self, app, allowed_hosts: set[str] | None = None):
self.app = app
self.allowed_hosts = allowed_hosts if allowed_hosts is not None else default_allowed_hosts()
async def __call__(self, scope, receive, send):
if scope["type"] == "http":
host = ""
for name, value in scope.get("headers", []):
if name == b"host":
host = value.decode("latin-1")
break
if not host_allowed(host, self.allowed_hosts):
body = b'{"detail": "Host not allowed"}'
await send(
{
"type": "http.response.start",
"status": 403,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(body)).encode()),
],
}
)
await send({"type": "http.response.body", "body": body})
return
await self.app(scope, receive, send)
app.add_middleware(HostAllowlistMiddleware)
def _resolve_context(request: Request) -> UserContext:
"""根据 X-API-Key 解析出对应用户上下文;静态 Key/无头 → 默认凭据。"""
state = request.app.state
@@ -186,7 +244,11 @@ def _resolve_context(request: Request) -> UserContext:
(header,),
).fetchone()
if row is not None:
cache_key = (row["user_id"], row["pass_token"], row["region"])
# passToken 优先从系统 keyring 读取;为空则回退 DB 列(无 keyring 环境的降级存储)
pass_token = load_api_key_secret(header) or row["pass_token"]
if not pass_token:
raise HTTPException(status_code=401, detail="API key credentials unavailable")
cache_key = (row["user_id"], pass_token, row["region"])
ctx = state.contexts.get(cache_key)
if ctx is None:
ctx = _build_context(state.db, state.config, *cache_key)
@@ -249,10 +311,12 @@ class CreateKeyRequest(BaseModel):
def _create_key_record(db: Database, user_id: str, pass_token: str, region: str, label: str | None) -> str:
key = "mif_sk_" + secrets.token_hex(20)
# passToken 存系统 keyring(加密);keyring 不可用才降级写 DB 明文列
stored = store_api_key_secret(key, pass_token)
with db._get_connection() as conn:
conn.execute(
"INSERT INTO api_keys (key, label, user_id, pass_token, region, created_at) VALUES (?,?,?,?,?,?)",
(key, label, user_id, pass_token, region, datetime.now(UTC).isoformat()),
(key, label, user_id, "" if stored else pass_token, region, datetime.now(UTC).isoformat()),
)
conn.commit()
return key
@@ -322,6 +386,7 @@ async def revoke_key(key_prefix: str, request: Request) -> dict:
raise HTTPException(status_code=404, detail="没有匹配的 Key(可用完整 Key 或唯一前缀)")
for k in matches:
conn.execute("UPDATE api_keys SET revoked = 1 WHERE key = ?", (k,))
delete_api_key_secret(k)
conn.commit()
return {"status": "ok", "revoked": matches}
@@ -761,3 +826,69 @@ def get_coverage(
) -> dict:
data = _query_service(request).get_data_coverage(data_types)
return {"status": "ok", "count": len(data), "data": data}
def _to_csv(rows: list[dict[str, Any]]) -> str:
"""把查询结果扁平化为 CSV(嵌套结构转 JSON 字符串,加 BOM 便于 Excel 打开中文)。"""
import csv
import io
fieldnames: list[str] = []
for row in rows:
for name in row:
if name not in fieldnames:
fieldnames.append(name)
buf = io.StringIO()
writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction="ignore")
writer.writeheader()
for row in rows:
writer.writerow(
{
k: (json.dumps(v, ensure_ascii=False) if isinstance(v, (list, dict)) else v)
for k, v in row.items()
}
)
return "\ufeff" + buf.getvalue()
EXPORT_SOURCES = {
"daily_activity": lambda qs, s, e: qs.get_daily_summaries(s, e),
"heart_rate": lambda qs, s, e: qs.get_heart_rate_samples(s, e),
"sleep": lambda qs, s, e: qs.get_sleep_sessions(s, e),
"workouts": lambda qs, s, e: qs.get_workouts(s, e),
"body_measurements": lambda qs, s, e: qs.get_body_measurements(s, e),
"spo2": lambda qs, s, e: qs.get_spo2_samples(s, e),
"stress": lambda qs, s, e: qs.get_stress_samples(s, e),
"abnormal_heart_beat": lambda qs, s, e: qs.get_abnormal_heart_beat_events(s, e),
}
@app.get("/api/export", response_model=None)
def export_data(
request: Request,
data_type: str,
start_date: str,
end_date: str,
format: str = "json",
) -> Response | dict:
"""导出本地数据(JSON 或 CSV)。仅读本地 SQLite,不触发任何网络请求。"""
if data_type not in EXPORT_SOURCES:
raise HTTPException(status_code=400, detail=f"data_type 必须是 {sorted(EXPORT_SOURCES)} 之一")
if format not in ("json", "csv"):
raise HTTPException(status_code=400, detail="format 必须是 json 或 csv")
data = EXPORT_SOURCES[data_type](
_query_service(request),
_validate_date(start_date, "start_date"),
_validate_date(end_date, "end_date"),
)
if format == "csv":
return Response(
content=_to_csv(data),
media_type="text/csv; charset=utf-8",
headers={
"Content-Disposition": (
f'attachment; filename="mi_fitness_{data_type}_{start_date}_{end_date}.csv"'
)
},
)
return {"status": "ok", "count": len(data), "data": data}
+13 -4
View File
@@ -149,15 +149,24 @@ def cmd_api(args):
import uvicorn
if args.host not in ("127.0.0.1", "localhost") and not os.environ.get("MI_FITNESS_API_KEY"):
print("⚠️ 绑定到非本机地址但未设置 MI_FITNESS_API_KEY,健康数据将暴露给局域网。")
print(" 建议先设置环境变量 MI_FITNESS_API_KEY 再启动。")
uvicorn.run("mi_fitness_mcp.api:app", host=args.host, port=args.port)
loopback = ("127.0.0.1", "localhost", "::1")
if args.host not in loopback:
# 非回环绑定:Host 白名单放行该地址(默认仅本机域名,见 security.py)
os.environ.setdefault("MI_FITNESS_ALLOWED_HOSTS", args.host)
if not os.environ.get("MI_FITNESS_API_KEY"):
print("⚠️ 绑定到非本机地址但未设置 MI_FITNESS_API_KEY,健康数据将暴露给局域网。")
print(" 建议先设置环境变量 MI_FITNESS_API_KEY 再启动。")
# 关闭访问日志:查询串中的日期等参数不再落入终端/日志
uvicorn.run("mi_fitness_mcp.api:app", host=args.host, port=args.port, access_log=False)
def cmd_web(args):
import os
from mi_fitness_mcp.web import run_server
if args.host not in ("127.0.0.1", "localhost", "::1"):
os.environ.setdefault("MI_FITNESS_ALLOWED_HOSTS", args.host)
run_server(
host=args.host,
port=args.port,
+84
View File
@@ -0,0 +1,84 @@
"""本地安全加固工具:Host 校验、CORS 解析与 API Key 密钥的 keyring 存取。
设计目标(见 docs/PRIVACY.md):
- 浏览器中的恶意网页无法读取本地 API 的健康数据(CORS 默认仅本机来源);
- DNS 重绑定攻击无法绕过同源策略(Host 头白名单);
- 发放的 API Key 对应的 passToken 不落 SQLite 明文,存入系统 keyring
(Windows DPAPI / macOS Keychain / Linux Secret Service),keyring 不可用时
才回退到数据库列并保持兼容。
"""
import hashlib
import os
SERVICE_NAME = "mi-fitness-mcp"
LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1", "[::1]"}
def default_allowed_hosts() -> set[str]:
"""Host 白名单。默认仅本机回环;环境变量 MI_FITNESS_ALLOWED_HOSTS 可覆盖(逗号分隔,* 放行全部)。"""
env = os.environ.get("MI_FITNESS_ALLOWED_HOSTS")
if env:
return {h.strip().lower() for h in env.split(",") if h.strip()}
return set(LOOPBACK_HOSTS)
def host_allowed(host: str | None, allowed_hosts: set[str]) -> bool:
"""校验请求 Host 头是否在白名单内(自动剥离端口,支持 IPv6 方括号写法)。"""
if "*" in allowed_hosts:
return True
if not host:
return False
h = host.strip().lower()
if h.startswith("["):
end = h.find("]")
if end != -1:
h = h[: end + 1]
elif ":" in h:
h = h.rsplit(":", 1)[0]
return h in allowed_hosts
def cors_origins_from_env(default_regex: str) -> dict:
"""CORS 配置:环境变量 MI_FITNESS_CORS_ORIGINS 提供精确来源列表,否则用本机正则默认值。"""
env = os.environ.get("MI_FITNESS_CORS_ORIGINS")
if env:
origins = [o.strip() for o in env.split(",") if o.strip()]
if origins:
return {"allow_origins": origins}
return {"allow_origin_regex": default_regex}
def _account_for(api_key: str) -> str:
digest = hashlib.sha256(api_key.encode()).hexdigest()[:16]
return f"apikey_{digest}"
def store_api_key_secret(api_key: str, pass_token: str) -> bool:
"""把 API Key 对应的 passToken 存入系统 keyring。返回是否成功。"""
try:
import keyring
keyring.set_password(SERVICE_NAME, _account_for(api_key), pass_token)
return True
except Exception:
return False
def load_api_key_secret(api_key: str) -> str | None:
try:
import keyring
return keyring.get_password(SERVICE_NAME, _account_for(api_key))
except Exception:
return None
def delete_api_key_secret(api_key: str) -> None:
try:
import keyring
keyring.delete_password(SERVICE_NAME, _account_for(api_key))
except Exception:
pass
+9
View File
@@ -22,6 +22,8 @@ from flask import (
send_from_directory,
)
from mi_fitness_mcp.security import default_allowed_hosts, host_allowed
# Assets directory: src/mi_fitness_mcp/web_assets or fallback to testpage
PACKAGE_DIR = Path(__file__).resolve().parent
DEFAULT_ASSETS_DIR = PACKAGE_DIR / "web_assets"
@@ -55,6 +57,13 @@ def create_app(
# Persistent HTTP client with 300s timeout (trust_env=False avoids proxy interference on localhost)
http_client = httpx.Client(trust_env=False, timeout=300.0, follow_redirects=True)
@app.before_request
def _check_host():
# Host 头白名单(防 DNS 重绑定):默认仅本机域名,环境变量可覆盖
if not host_allowed(request.host, default_allowed_hosts()):
return jsonify({"detail": "Host not allowed"}), 403
return None
@app.route("/")
def index():
"""Render the main dashboard interface."""