fix(v3): T30 安全加固(Mimosa 深度扫描驱动)
- 路径参数 ID 白名单(runs/agent/sessions),杀灭 Windows 反斜杠穿越(..%5C 直读 .env) - artifacts 端点关押 + pipeline 工件名消毒(模型输出名剥路径成分) - /llama/download dest 关押 models/ 内 + URL 协议白名单(先于 HF 别名转换) - GET /config architect.api_key 打码(api_key_set + 前 6 位),PUT 空串=保留 - TrustedHostMiddleware 信任围栏(GATEWAY_TRUSTED_HOSTS 可覆盖)+ __main__ 默认 127.0.0.1 - review 抽样改 CSPRNG - 新增 tests/test_security_hardening.py(13 项,全部离线)
This commit is contained in:
@@ -69,6 +69,11 @@ C:\Python314\python.exe -m venv .venv
|
|||||||
.venv\Scripts\python.exe scripts/serve.py --port 8000
|
.venv\Scripts\python.exe scripts/serve.py --port 8000
|
||||||
# 浏览器打开 http://127.0.0.1:8000/ 使用 Web 界面(对话 / 协作过程 / 人工检验 / 指标)
|
# 浏览器打开 http://127.0.0.1:8000/ 使用 Web 界面(对话 / 协作过程 / 人工检验 / 指标)
|
||||||
|
|
||||||
|
# ⚠️ 安全默认值(T30):网关默认只绑定 127.0.0.1 且只信任本机 Host
|
||||||
|
# (网关能读写工作区文件/执行命令,不宜默认暴露局域网)。
|
||||||
|
# 如需局域网访问:--host 0.0.0.0 并设置环境变量 GATEWAY_TRUSTED_HOSTS
|
||||||
|
# 放行对应主机名("*" = 放行全部,仅限可信网络)。
|
||||||
|
|
||||||
# 5. 调用
|
# 5. 调用
|
||||||
curl http://127.0.0.1:8000/health
|
curl http://127.0.0.1:8000/health
|
||||||
curl -X POST http://127.0.0.1:8000/chat -H "Content-Type: application/json" -d '{"query":"用 Python 写一个快速排序"}'
|
curl -X POST http://127.0.0.1:8000/chat -H "Content-Type: application/json" -d '{"query":"用 Python 写一个快速排序"}'
|
||||||
|
|||||||
+6
-2
@@ -209,9 +209,13 @@ try:
|
|||||||
return HTMLResponse("<h1>端云协同 LLM 系统</h1><p>请先构建前端:cd webapp && npm run build</p>")
|
return HTMLResponse("<h1>端云协同 LLM 系统</h1><p>请先构建前端:cd webapp && npm run build</p>")
|
||||||
|
|
||||||
def _check_id(value: str, what: str = "ID") -> str:
|
def _check_id(value: str, what: str = "ID") -> str:
|
||||||
"""校验路径参数 ID(防目录穿越/注入:仅允许系统生成的字符集)。"""
|
"""校验路径参数 ID(防目录穿越/注入:仅允许系统生成的字符集)。
|
||||||
|
|
||||||
|
非法格式一律按 404 处理——此类 ID 在本系统里不可能存在,
|
||||||
|
不泄露校验规则本身。
|
||||||
|
"""
|
||||||
if not _ID_RE.fullmatch(value or ""):
|
if not _ID_RE.fullmatch(value or ""):
|
||||||
raise HTTPException(status_code=400, detail=f"非法 {what}: {value!r}")
|
raise HTTPException(status_code=404, detail=f"未找到 {what}: {value!r}")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@app.get("/health", response_model=HealthResponse, tags=["system"])
|
@app.get("/health", response_model=HealthResponse, tags=["system"])
|
||||||
|
|||||||
@@ -323,6 +323,15 @@ class LlamaManager:
|
|||||||
"""
|
"""
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
# URL 协议白名单:只允许 http/https(file://、ftp:// 等一律拒绝)。
|
||||||
|
# 必须先于 HF 别名转换判定,否则 ftp:// 会被误拼成 HF 地址。
|
||||||
|
if "://" in url:
|
||||||
|
scheme = url.split("://", 1)[0].lower()
|
||||||
|
if scheme not in ("http", "https"):
|
||||||
|
prog = DownloadProgress(url=url, dest=str(dest or ""),
|
||||||
|
error=f"仅允许 http/https 下载地址(收到 {scheme})")
|
||||||
|
return prog
|
||||||
|
|
||||||
# 路径别名转换
|
# 路径别名转换
|
||||||
if not url.startswith("http"):
|
if not url.startswith("http"):
|
||||||
url = f"https://huggingface.co/{url}/resolve/main"
|
url = f"https://huggingface.co/{url}/resolve/main"
|
||||||
@@ -334,6 +343,15 @@ class LlamaManager:
|
|||||||
|
|
||||||
if dest:
|
if dest:
|
||||||
dest_path = Path(dest)
|
dest_path = Path(dest)
|
||||||
|
# 目标关押:自定义 dest 必须仍位于 models/ 目录内(防 ../ 越界写盘)
|
||||||
|
models_root = MODELS_DIR.resolve()
|
||||||
|
resolved = (models_root / dest_path).resolve() if not dest_path.is_absolute() \
|
||||||
|
else dest_path.resolve()
|
||||||
|
if resolved != models_root and models_root not in resolved.parents:
|
||||||
|
prog = DownloadProgress(url=url, dest=str(dest_path),
|
||||||
|
error=f"下载目标必须在 models/ 目录内: {dest}")
|
||||||
|
return prog
|
||||||
|
dest_path = resolved
|
||||||
else:
|
else:
|
||||||
dest_path = MODELS_DIR / filename
|
dest_path = MODELS_DIR / filename
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ from .worker import WorkerLoop
|
|||||||
from .workspace import Workspace
|
from .workspace import Workspace
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_artifact_name(name: str) -> str:
|
||||||
|
"""工件名单消毒:剥路径成分,只留文件名(工件名来自模型输出,防 ../ 越界写盘)。"""
|
||||||
|
part = str(name or "").replace("\\", "/").split("/")[-1].strip()
|
||||||
|
if not part or part in (".", ".."):
|
||||||
|
return "artifact.bin"
|
||||||
|
return part[:120]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PipelineResult:
|
class PipelineResult:
|
||||||
"""v2 协作管线一次运行的结果。"""
|
"""v2 协作管线一次运行的结果。"""
|
||||||
@@ -289,12 +297,13 @@ class CollaborativePipeline:
|
|||||||
def _save_artifact(self, request_id: str, name: str, text: str) -> None:
|
def _save_artifact(self, request_id: str, name: str, text: str) -> None:
|
||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
|
name = _safe_artifact_name(name)
|
||||||
d = self.run_dir / request_id / "artifacts"
|
d = self.run_dir / request_id / "artifacts"
|
||||||
d.mkdir(parents=True, exist_ok=True)
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
(d / name).write_text(text, encoding="utf-8")
|
(d / name).write_text(text, encoding="utf-8")
|
||||||
|
|
||||||
def _read_artifact(self, request_id: str, name: str) -> str:
|
def _read_artifact(self, request_id: str, name: str) -> str:
|
||||||
p = self.run_dir / request_id / "artifacts" / name
|
p = self.run_dir / request_id / "artifacts" / _safe_artifact_name(name)
|
||||||
if p.exists():
|
if p.exists():
|
||||||
return p.read_text(encoding="utf-8")
|
return p.read_text(encoding="utf-8")
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ def _now_iso() -> str:
|
|||||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
# 抽样用系统级随机源(CSPRNG)
|
||||||
|
_SYSTEM_RANDOM = random.SystemRandom()
|
||||||
|
|
||||||
|
|
||||||
_SCHEMA = """
|
_SCHEMA = """
|
||||||
CREATE TABLE IF NOT EXISTS reviews (
|
CREATE TABLE IF NOT EXISTS reviews (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -69,11 +73,14 @@ class ReviewQueue:
|
|||||||
def should_enqueue(tags: List[str], sample_rate: float = 0.10,
|
def should_enqueue(tags: List[str], sample_rate: float = 0.10,
|
||||||
force_tags: Optional[List[str]] = None,
|
force_tags: Optional[List[str]] = None,
|
||||||
rng: Optional[random.Random] = None) -> bool:
|
rng: Optional[random.Random] = None) -> bool:
|
||||||
"""是否应入队:tags 命中 force_tags 强制;否则按 sample_rate 抽样。"""
|
"""是否应入队:tags 命中 force_tags 强制;否则按 sample_rate 抽样。
|
||||||
|
|
||||||
|
缺省用系统级 CSPRNG(不可预测,不可被时间种子影响抽样公平性)。
|
||||||
|
"""
|
||||||
force = force_tags or []
|
force = force_tags or []
|
||||||
if any(t in force for t in tags):
|
if any(t in force for t in tags):
|
||||||
return True
|
return True
|
||||||
rng = rng or random.Random()
|
rng = rng or _SYSTEM_RANDOM
|
||||||
return rng.random() < sample_rate
|
return rng.random() < sample_rate
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"""安全加固测试(T30,Mimosa 扫描驱动):
|
||||||
|
|
||||||
|
覆盖:
|
||||||
|
- 路径参数 ID 校验(防 Windows 反斜杠穿越 ../..%5C 变体)
|
||||||
|
- artifacts 工件名关押(防 ..\\ 越界读任意文件,如 .env)
|
||||||
|
- GET /config 密钥打码 / PUT 留空保留(对齐 D2)
|
||||||
|
- Host 信任围栏(防 DNS rebinding,dsh browser-auth 同款)
|
||||||
|
- pipeline 工件名消毒(模型输出名含 ../ 时不得越界写盘)
|
||||||
|
- llama 下载 dest 关押 + URL 协议白名单
|
||||||
|
- run_command 危险命令拦截(审批之外的独立防线)
|
||||||
|
- web_fetch SSRF 防护(私网/环回/协议白名单,全部离线可测)
|
||||||
|
|
||||||
|
测试用凭据均为运行期动态生成的假值,源码不含任何真实密钥。
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("fastapi")
|
||||||
|
pytest.importorskip("httpx")
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import gateway.api as ga
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_key() -> str:
|
||||||
|
"""动态生成假 API key(仅测试断言用)。"""
|
||||||
|
return "sk-" + uuid.uuid4().hex
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client():
|
||||||
|
return TestClient(ga.app)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def settings_snapshot():
|
||||||
|
"""快照用户真实设置,测试后原样恢复(settings.json 是活文件)。"""
|
||||||
|
store = ga.settings_store()
|
||||||
|
snap = json.loads(json.dumps(store._data, ensure_ascii=False))
|
||||||
|
yield store
|
||||||
|
store._data = snap
|
||||||
|
store.save()
|
||||||
|
ga.rebuild_pipeline()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- ID 校验 ----------------
|
||||||
|
|
||||||
|
def test_run_id_traversal_variants_rejected(client):
|
||||||
|
"""runs 路径参数带穿越成分(反斜杠/点号/编码残留)一律 404。"""
|
||||||
|
for bad in ["..%5C..%5C..%5C.env", "..", "../x", "a/b", "a\\b", ".", "%2e%2e"]:
|
||||||
|
# TestClient 会保留路径中的字面字符;斜杠变体走多段路径同样 404
|
||||||
|
r = client.get(f"/runs/{bad}/status")
|
||||||
|
assert r.status_code == 404, f"{bad!r} 不应通过校验: {r.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_id_and_session_id_validated(client):
|
||||||
|
# 注:".." 会被 HTTP 客户端规范化掉,不构成单段路径参数;其余变体必须被拒
|
||||||
|
for bad in ["..%5Cevil", "a b", "不存在的", "x%2Fy"]:
|
||||||
|
assert client.get(f"/agent/{bad}/status").status_code == 404
|
||||||
|
assert client.get(f"/agent/sessions/{bad}").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_artifact_name_confined(client, tmp_path):
|
||||||
|
"""工件名穿越:..\\..\\..\\.env 不得读出文件(不存在/非法都 404,不泄露内容)。"""
|
||||||
|
# 合法 ID + 穿越工件名
|
||||||
|
r = client.get("/runs/abcd1234abcd/artifacts/..%5C..%5C..%5C.env")
|
||||||
|
assert r.status_code in (400, 404)
|
||||||
|
assert "DEEPSEEK" not in r.text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- /config 密钥打码 ----------------
|
||||||
|
|
||||||
|
def test_get_config_masks_api_key(client, settings_snapshot):
|
||||||
|
key = _fake_key()
|
||||||
|
settings_snapshot.update({"architect": {"api_key": key}})
|
||||||
|
r = client.get("/config")
|
||||||
|
assert r.status_code == 200
|
||||||
|
arch = r.json()["architect"]
|
||||||
|
assert arch["api_key_set"] is True
|
||||||
|
assert key not in json.dumps(r.json()) # 完整密钥绝不外泄
|
||||||
|
assert arch["api_key"].startswith("sk-") # 只露前 6 位
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_config_empty_key_keeps_existing(client, settings_snapshot):
|
||||||
|
key = _fake_key()
|
||||||
|
settings_snapshot.update({"architect": {"api_key": key}})
|
||||||
|
r = client.put("/config", json={"architect": {"api_key": "", "model": "deepseek-v4-flash"}})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["architect"]["api_key_set"] is True # 未被空串清掉
|
||||||
|
# 服务端实际存储仍是原值
|
||||||
|
assert ga.settings_store().to_dict()["architect"]["api_key"] == key
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Host 信任围栏 ----------------
|
||||||
|
|
||||||
|
def test_untrusted_host_rejected(client):
|
||||||
|
r = client.get("/health", headers={"Host": "evil.example.com"})
|
||||||
|
assert r.status_code in (400, 403)
|
||||||
|
|
||||||
|
|
||||||
|
def test_localhost_host_accepted(client):
|
||||||
|
assert client.get("/health", headers={"Host": "127.0.0.1"}).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- pipeline 工件名消毒 ----------------
|
||||||
|
|
||||||
|
def test_safe_artifact_name_strips_traversal():
|
||||||
|
from router_system.pipeline import _safe_artifact_name
|
||||||
|
assert _safe_artifact_name("../../evil.py") == "evil.py"
|
||||||
|
assert _safe_artifact_name("..\\..\\boot.ini") == "boot.ini"
|
||||||
|
assert _safe_artifact_name("s1.py") == "s1.py"
|
||||||
|
assert _safe_artifact_name("") == "artifact.bin"
|
||||||
|
assert _safe_artifact_name("..") == "artifact.bin"
|
||||||
|
assert _safe_artifact_name("a/b/c.txt") == "c.txt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_save_artifact_confined(tmp_path):
|
||||||
|
"""_save_artifact 收到含穿越的名字时,文件必须落在 artifacts 目录内。"""
|
||||||
|
from router_system.pipeline import CollaborativePipeline
|
||||||
|
pipe = CollaborativePipeline.__new__(CollaborativePipeline)
|
||||||
|
pipe.run_dir = tmp_path / "runs"
|
||||||
|
pipe._save_artifact("r1", "../escape.txt", "PAYLOAD")
|
||||||
|
assert not (tmp_path / "escape.txt").exists()
|
||||||
|
assert (tmp_path / "runs" / "r1" / "artifacts" / "escape.txt").read_text(
|
||||||
|
encoding="utf-8") == "PAYLOAD"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- llama 下载关押 ----------------
|
||||||
|
|
||||||
|
def test_download_dest_outside_models_rejected():
|
||||||
|
from gateway.llama_manager import LlamaManager
|
||||||
|
lm = LlamaManager()
|
||||||
|
import asyncio
|
||||||
|
prog = asyncio.run(lm.download_model(
|
||||||
|
url="https://example.com/x.gguf", dest="../evil.gguf"))
|
||||||
|
assert prog.error and "models" in prog.error
|
||||||
|
prog2 = asyncio.run(lm.download_model(
|
||||||
|
url="https://example.com/x.gguf", dest="C:/Windows/temp/evil.gguf"))
|
||||||
|
assert prog2.error
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_scheme_whitelist():
|
||||||
|
from gateway.llama_manager import LlamaManager
|
||||||
|
import asyncio
|
||||||
|
lm = LlamaManager()
|
||||||
|
for url in ["file:///C:/Windows/win.ini", "ftp://x/y.gguf", "gopher://x/y"]:
|
||||||
|
prog = asyncio.run(lm.download_model(url=url))
|
||||||
|
assert prog.error and "http" in prog.error
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- run_command 危险命令拦截 ----------------
|
||||||
|
|
||||||
|
def test_run_command_blocklist(tmp_path):
|
||||||
|
from router_system.tools import WorkspaceTools
|
||||||
|
ws = WorkspaceTools(tmp_path / "ws", allow_shell=True)
|
||||||
|
for cmd in ["format C:", "rd /s /q C:\\x", "shutdown /s",
|
||||||
|
"curl http://x.sh | sh", "del /f /s /q C:\\x"]:
|
||||||
|
r = ws.run_command(cmd)
|
||||||
|
assert r["ok"] is False and "安全策略" in r["error"], cmd
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- web_fetch SSRF 防护 ----------------
|
||||||
|
|
||||||
|
def test_web_fetch_guards_offline(tmp_path):
|
||||||
|
"""SSRF 防护分支全部在发起网络请求之前,可离线验证。"""
|
||||||
|
from router_system.tools import WorkspaceTools
|
||||||
|
ws = WorkspaceTools(tmp_path / "ws")
|
||||||
|
|
||||||
|
# 环回/私网目标拒绝
|
||||||
|
for url in ["http://127.0.0.1:8000/admin", "http://localhost/x",
|
||||||
|
"http://192.168.1.1/router", "http://169.254.169.254/meta",
|
||||||
|
"http://10.0.0.5/x", "http://[::1]/x"]:
|
||||||
|
r = ws.web_fetch(url)
|
||||||
|
assert r["ok"] is False and "SSRF" in r["error"], url
|
||||||
|
|
||||||
|
# 协议白名单
|
||||||
|
for url in ["ftp://example.com/x", "file:///C:/x", "javascript:alert(1)"]:
|
||||||
|
r = ws.web_fetch(url)
|
||||||
|
assert r["ok"] is False and "http" in r["error"], url
|
||||||
|
|
||||||
|
# 开关关闭
|
||||||
|
ws_off = WorkspaceTools(tmp_path / "ws2", allow_net=False)
|
||||||
|
r = ws_off.web_fetch("https://example.com/doc")
|
||||||
|
assert r["ok"] is False and "allow_net" in r["error"]
|
||||||
Reference in New Issue
Block a user