浏览代码

fix(trace-api): 将调试服务严格限制为本机单用户

统一把 API、Vite 开发服务和预览服务绑定到 127.0.0.1,移除远端代理地址。

HTTP 同时校验真实 loopback peer、Host 和 Origin;Trace、日志及 ping WebSocket 在 accept 前执行相同的来源检查,阻断 DNS rebinding 和伪造本地 Host 的远程访问。

默认关闭热重载,并通过 .trace 单写者文件锁拒绝第二个 API 写进程;健康检查明确返回 local_development、loopback_only 和无认证边界。

新增 12 项本地边界回归,覆盖 IPv4/IPv6 loopback、非本地 peer、CORS、Host、WebSocket Origin 与 reload 开关。
SamLee 13 小时之前
父节点
当前提交
ca7a7accb4

+ 117 - 6
api_server.py

@@ -8,13 +8,20 @@ API Server - FastAPI 应用入口
 - GET  /api/experiences — 经验查询(trace/run_api.py,需配置 Runner)
 """
 
-import logging
 import json
+import logging
 import os
+import fcntl
+from ipaddress import ip_address
+from pathlib import Path
+
 from dotenv import load_dotenv
+
 load_dotenv()
 from fastapi import FastAPI, Request, WebSocket
+from fastapi.responses import JSONResponse
 from fastapi.middleware.cors import CORSMiddleware
+from fastapi.middleware.trustedhost import TrustedHostMiddleware
 import uvicorn
 
 from cyber_agent.trace import FileSystemTraceStore
@@ -34,6 +41,70 @@ logging.basicConfig(
 )
 logger = logging.getLogger(__name__)
 
+
+# ===== 单用户本地开发边界 =====
+
+LOCAL_DEVELOPMENT_ORIGINS = (
+    "http://127.0.0.1:3000",
+    "http://localhost:3000",
+)
+LOCAL_DEVELOPMENT_HOSTS = ("127.0.0.1", "localhost")
+_writer_lock_file = None
+
+
+def _env_flag(name: str, *, default: bool = False) -> bool:
+    """解析显式开关;未配置和未识别的值均按关闭处理。"""
+    value = os.getenv(name)
+    if value is None:
+        return default
+    return value.strip().lower() in {"1", "true", "yes", "on"}
+
+
+def _is_local_websocket_origin(websocket: WebSocket) -> bool:
+    """浏览器 WebSocket 必须来自本地前端。"""
+    return websocket.headers.get("origin") in LOCAL_DEVELOPMENT_ORIGINS
+
+
+def _is_loopback_host(host: str | None) -> bool:
+    """Only numeric IPv4/IPv6 loopback peers are trusted."""
+    if not host:
+        return False
+    try:
+        return ip_address(host).is_loopback
+    except ValueError:
+        return False
+
+
+def _is_loopback_websocket_peer(websocket: WebSocket) -> bool:
+    return _is_loopback_host(websocket.client.host if websocket.client else None)
+
+
+def _acquire_single_writer_lock():
+    """Fail startup when another API process owns this Trace directory."""
+    global _writer_lock_file
+    if _writer_lock_file is not None:
+        return
+    lock_path = Path(".trace").resolve() / ".api-writer.lock"
+    lock_path.parent.mkdir(parents=True, exist_ok=True)
+    handle = lock_path.open("a+")
+    try:
+        fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
+    except BlockingIOError as exc:
+        handle.close()
+        raise RuntimeError(
+            f"another Agent API writer already owns {lock_path.parent}"
+        ) from exc
+    _writer_lock_file = handle
+
+
+def _release_single_writer_lock():
+    global _writer_lock_file
+    if _writer_lock_file is None:
+        return
+    fcntl.flock(_writer_lock_file.fileno(), fcntl.LOCK_UN)
+    _writer_lock_file.close()
+    _writer_lock_file = None
+
 # 设置WebSocket日志推送
 setup_websocket_logging(level=logging.INFO)
 
@@ -49,12 +120,35 @@ app = FastAPI(
 # CORS 配置(允许前端跨域访问)
 app.add_middleware(
     CORSMiddleware,
-    allow_origins=["*"],  # 生产环境应限制具体域名
-    allow_credentials=True,
+    allow_origins=list(LOCAL_DEVELOPMENT_ORIGINS),
+    allow_credentials=False,
     allow_methods=["*"],
     allow_headers=["*"],
 )
 
+# 拒绝非本地 Host,防止误暴露和 DNS rebinding。
+app.add_middleware(
+    TrustedHostMiddleware,
+    allowed_hosts=list(LOCAL_DEVELOPMENT_HOSTS),
+)
+
+
+@app.middleware("http")
+async def reject_non_local_browser_origins(request: Request, call_next):
+    """Fail closed unless the TCP peer and optional browser Origin are local."""
+    if not _is_loopback_host(request.client.host if request.client else None):
+        return JSONResponse(
+            status_code=403,
+            content={"detail": "Loopback client required"},
+        )
+    origin = request.headers.get("origin")
+    if origin is not None and origin not in LOCAL_DEVELOPMENT_ORIGINS:
+        return JSONResponse(
+            status_code=403,
+            content={"detail": "Local development origin required"},
+        )
+    return await call_next(request)
+
 
 # ===== 初始化存储 =====
 
@@ -109,11 +203,25 @@ app.include_router(logs_router)
 @app.on_event("startup")
 async def on_startup():
     """服务器启动时执行状态对齐"""
+    _acquire_single_writer_lock()
+    for trace in await trace_store.list_traces(limit=10_000):
+        await trace_store.reconcile_trace_integrity(trace.trace_id)
     from cyber_agent.trace.run_api import reconcile_traces
     await reconcile_traces()
 
+
+@app.on_event("shutdown")
+async def on_shutdown():
+    _release_single_writer_lock()
+
 @app.websocket("/ws_ping")
 async def ws_ping(websocket: WebSocket):
+    if not _is_loopback_websocket_peer(websocket):
+        await websocket.close(code=1008, reason="Loopback client required")
+        return
+    if not _is_local_websocket_origin(websocket):
+        await websocket.close(code=1008, reason="Local development origin required")
+        return
     await websocket.accept()
     await websocket.send_text("pong")
     await websocket.close()
@@ -127,7 +235,10 @@ async def health_check():
     return {
         "status": "ok",
         "service": "Agent Step Tree API",
-        "version": "1.0.0"
+        "version": "1.0.0",
+        "deployment_mode": "local_development",
+        "network_scope": "loopback_only",
+        "authentication": "none",
     }
 
 
@@ -137,8 +248,8 @@ if __name__ == "__main__":
     logger.info("Starting API server...")
     uvicorn.run(
         "api_server:app",
-        host="0.0.0.0",
+        host="127.0.0.1",
         port=8000,
-        reload=True,  # 开发模式
+        reload=_env_flag("TRACE_API_RELOAD"),
         log_level="info"
     )

+ 29 - 0
cyber_agent/trace/logs_websocket.py

@@ -4,6 +4,7 @@ Logs WebSocket - 实时推送后端日志到前端
 
 import asyncio
 import logging
+from ipaddress import ip_address
 from typing import Set
 from fastapi import APIRouter, WebSocket, WebSocketDisconnect
 from datetime import datetime
@@ -11,11 +12,32 @@ from datetime import datetime
 
 router = APIRouter(prefix="/api/logs", tags=["logs"])
 
+LOCAL_DEVELOPMENT_ORIGINS = frozenset({
+    "http://127.0.0.1:3000",
+    "http://localhost:3000",
+})
+
 
 # 存储所有连接的WebSocket客户端
 _clients: Set[WebSocket] = set()
 
 
+def is_local_websocket_origin(websocket: WebSocket) -> bool:
+    """日志流可能含敏感调试信息,只允许本地前端连接。"""
+    return websocket.headers.get("origin") in LOCAL_DEVELOPMENT_ORIGINS
+
+
+def is_loopback_websocket_peer(websocket: WebSocket) -> bool:
+    """Reject absent, non-IP, and non-loopback ASGI peers."""
+    host = websocket.client.host if websocket.client else None
+    if not host:
+        return False
+    try:
+        return ip_address(host).is_loopback
+    except ValueError:
+        return False
+
+
 class WebSocketLogHandler(logging.Handler):
     """自定义日志处理器,将日志推送到WebSocket客户端"""
 
@@ -69,6 +91,13 @@ async def logs_websocket(websocket: WebSocket):
 
     客户端连接后,实时接收后端日志
     """
+    if not is_loopback_websocket_peer(websocket):
+        await websocket.close(code=1008, reason="Loopback client required")
+        return
+    if not is_local_websocket_origin(websocket):
+        await websocket.close(code=1008, reason="Local development origin required")
+        return
+
     await websocket.accept()
     _clients.add(websocket)
 

+ 29 - 0
cyber_agent/trace/websocket.py

@@ -6,6 +6,7 @@ Trace WebSocket 推送
 
 from typing import Dict, Set, Any
 from datetime import datetime
+from ipaddress import ip_address
 import asyncio
 from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
 
@@ -14,6 +15,11 @@ from .protocols import TraceStore
 
 router = APIRouter(prefix="/api/traces", tags=["websocket"])
 
+LOCAL_DEVELOPMENT_ORIGINS = frozenset({
+    "http://127.0.0.1:3000",
+    "http://localhost:3000",
+})
+
 
 # ===== 全局状态 =====
 
@@ -35,6 +41,22 @@ def get_trace_store() -> TraceStore:
     return _trace_store
 
 
+def is_local_websocket_origin(websocket: WebSocket) -> bool:
+    """Trace 流只接受本地前端的浏览器 WebSocket。"""
+    return websocket.headers.get("origin") in LOCAL_DEVELOPMENT_ORIGINS
+
+
+def is_loopback_websocket_peer(websocket: WebSocket) -> bool:
+    """Reject absent, non-IP, and non-loopback ASGI peers."""
+    host = websocket.client.host if websocket.client else None
+    if not host:
+        return False
+    try:
+        return ip_address(host).is_loopback
+    except ValueError:
+        return False
+
+
 # ===== WebSocket 路由 =====
 
 
@@ -62,6 +84,13 @@ async def watch_trace(
             - 0: 补发所有历史事件(初次连接)
             - >0: 补发指定 ID 之后的事件(断线重连)
     """
+    if not is_loopback_websocket_peer(websocket):
+        await websocket.close(code=1008, reason="Loopback client required")
+        return
+    if not is_local_websocket_origin(websocket):
+        await websocket.close(code=1008, reason="Local development origin required")
+        return
+
     await websocket.accept()
 
     # 验证 Trace 存在

+ 2 - 2
frontend/react-template/package.json

@@ -4,9 +4,9 @@
   "version": "0.1.0",
   "type": "module",
   "scripts": {
-    "dev": "vite --host 0.0.0.0 --port 3000",
+    "dev": "vite --host 127.0.0.1 --port 3000",
     "build": "tsc && vite build",
-    "preview": "vite preview --host 0.0.0.0",
+    "preview": "vite preview --host 127.0.0.1",
     "test": "vitest",
     "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
   },

+ 4 - 2
frontend/react-template/vite.config.ts

@@ -12,11 +12,13 @@ export default defineConfig({
     },
   },
   server: {
+    host: "127.0.0.1",
     port: 3000,
     proxy: {
       "/api": {
-        target: "http://43.106.118.91:3000",
-        changeOrigin: true,
+        target: "http://127.0.0.1:8000",
+        changeOrigin: false,
+        ws: true,
       },
     },
   },

+ 206 - 0
tests/test_local_api_boundary.py

@@ -0,0 +1,206 @@
+"""Trace API single-user local-development boundary."""
+
+import os
+import unittest
+from unittest.mock import AsyncMock, patch
+
+from fastapi.testclient import TestClient
+from starlette.websockets import WebSocketDisconnect
+
+import api_server
+from cyber_agent.trace import websocket as trace_websocket
+
+
+LOCAL_HOST = {"host": "127.0.0.1:8000"}
+LOCAL_ORIGIN = "http://127.0.0.1:3000"
+LOCAL_CLIENT = ("127.0.0.1", 50000)
+
+
+def make_client(peer=LOCAL_CLIENT):
+    return TestClient(api_server.app, client=peer)
+
+
+class LocalApiBoundaryTest(unittest.TestCase):
+    def setUp(self) -> None:
+        self.list_patch = patch.object(
+            api_server.trace_store,
+            "list_traces",
+            new=AsyncMock(return_value=[]),
+        )
+        self.list_patch.start()
+
+    def tearDown(self) -> None:
+        self.list_patch.stop()
+
+    def test_health_declares_local_development_boundary(self) -> None:
+        with make_client() as client:
+            response = client.get("/health", headers=LOCAL_HOST)
+        self.assertEqual(200, response.status_code)
+        self.assertEqual("local_development", response.json()["deployment_mode"])
+        self.assertEqual("loopback_only", response.json()["network_scope"])
+        self.assertEqual("none", response.json()["authentication"])
+
+    def test_http_rejects_non_local_host(self) -> None:
+        for host in ("example.com", "api.internal.example"):
+            with self.subTest(host=host), make_client() as client:
+                response = client.get("/health", headers={"host": host})
+            self.assertEqual(400, response.status_code)
+
+    def test_http_rejects_non_loopback_peer_even_with_local_headers(self) -> None:
+        with make_client(("192.0.2.10", 50000)) as client:
+            response = client.get(
+                "/health",
+                headers={**LOCAL_HOST, "origin": LOCAL_ORIGIN},
+            )
+        self.assertEqual(403, response.status_code)
+        self.assertEqual("Loopback client required", response.json()["detail"])
+
+    def test_http_accepts_ipv6_loopback_peer(self) -> None:
+        with make_client(("::1", 50000)) as client:
+            response = client.get("/health", headers=LOCAL_HOST)
+        self.assertEqual(200, response.status_code)
+
+    def test_cors_allows_only_declared_local_frontends(self) -> None:
+        for origin in ("http://127.0.0.1:3000", "http://localhost:3000"):
+            with self.subTest(origin=origin), make_client() as client:
+                response = client.options(
+                    "/health",
+                    headers={
+                        **LOCAL_HOST,
+                        "origin": origin,
+                        "access-control-request-method": "GET",
+                    },
+                )
+            self.assertEqual(200, response.status_code)
+            self.assertEqual(origin, response.headers["access-control-allow-origin"])
+            self.assertNotIn("access-control-allow-credentials", response.headers)
+
+    def test_cors_rejects_non_local_origin(self) -> None:
+        with make_client() as client:
+            response = client.options(
+                "/health",
+                headers={
+                    **LOCAL_HOST,
+                    "origin": "https://attacker.example",
+                    "access-control-request-method": "GET",
+                },
+            )
+        self.assertEqual(403, response.status_code)
+
+        with make_client() as client:
+            simple = client.get(
+                "/health",
+                headers={
+                    **LOCAL_HOST,
+                    "origin": "https://attacker.example",
+                },
+            )
+        self.assertEqual(403, simple.status_code)
+
+    def test_websockets_reject_missing_or_non_local_origin(self) -> None:
+        for path in (
+            "/api/logs/watch",
+            "/api/traces/not-a-real-trace/watch",
+            "/ws_ping",
+        ):
+            for origin in (None, "https://attacker.example"):
+                headers = dict(LOCAL_HOST)
+                if origin is not None:
+                    headers["origin"] = origin
+                with self.subTest(path=path, origin=origin), make_client() as client:
+                    with self.assertRaises(WebSocketDisconnect) as caught:
+                        with client.websocket_connect(path, headers=headers):
+                            pass
+                self.assertEqual(1008, caught.exception.code)
+
+    def test_websockets_reject_non_loopback_peer_even_with_local_headers(self) -> None:
+        for path in (
+            "/api/logs/watch",
+            "/api/traces/not-a-real-trace/watch",
+            "/ws_ping",
+        ):
+            with self.subTest(path=path), make_client(("192.0.2.10", 50000)) as client:
+                with self.assertRaises(WebSocketDisconnect) as caught:
+                    with client.websocket_connect(
+                        path,
+                        headers={**LOCAL_HOST, "origin": LOCAL_ORIGIN},
+                    ):
+                        pass
+            self.assertEqual(1008, caught.exception.code)
+            self.assertEqual("Loopback client required", caught.exception.reason)
+
+    def test_local_logs_websocket_is_accepted(self) -> None:
+        with make_client() as client:
+            with client.websocket_connect(
+                "/api/logs/watch",
+                headers={**LOCAL_HOST, "origin": LOCAL_ORIGIN},
+            ) as websocket:
+                message = websocket.receive_json()
+        self.assertEqual("logs_websocket", message["name"])
+
+    def test_local_trace_websocket_is_accepted(self) -> None:
+        class _Trace:
+            trace_id = "trace-1"
+            last_event_id = 0
+            status = "completed"
+            parent_trace_id = None
+
+            def to_dict(self):
+                return {"trace_id": self.trace_id}
+
+        class _Store:
+            async def get_trace(self, trace_id):
+                return _Trace() if trace_id == "trace-1" else None
+
+            async def get_goal_tree(self, trace_id):
+                return None
+
+            async def list_traces(self, *, limit):
+                return []
+
+            async def get_events(self, trace_id, since_event_id):
+                return []
+
+        trace_websocket.set_trace_store(_Store())
+        try:
+            with make_client() as client:
+                with client.websocket_connect(
+                    "/api/traces/trace-1/watch",
+                    headers={**LOCAL_HOST, "origin": LOCAL_ORIGIN},
+                ) as websocket:
+                    message = websocket.receive_json()
+        finally:
+            trace_websocket.set_trace_store(api_server.trace_store)
+        self.assertEqual("connected", message["event"])
+
+    def test_local_ping_websocket_is_accepted(self) -> None:
+        with make_client() as client:
+            with client.websocket_connect(
+                "/ws_ping",
+                headers={**LOCAL_HOST, "origin": LOCAL_ORIGIN},
+            ) as websocket:
+                self.assertEqual("pong", websocket.receive_text())
+
+    def test_reload_requires_explicit_true(self) -> None:
+        for value, expected in (
+            (None, False),
+            ("true", True),
+            ("1", True),
+            ("yes", True),
+            ("on", True),
+            ("false", False),
+            ("unexpected", False),
+        ):
+            environment = {} if value is None else {"TRACE_API_RELOAD": value}
+            with self.subTest(value=value), patch.dict(
+                os.environ,
+                environment,
+                clear=False,
+            ):
+                if value is None:
+                    os.environ.pop("TRACE_API_RELOAD", None)
+                self.assertIs(expected, api_server._env_flag("TRACE_API_RELOAD"))
+
+
+if __name__ == "__main__":
+    unittest.main()