mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 02:41:58 +00:00
a05efbf27a
以前服务端一个安全头都不发,任何站点都能把 AgentLab 嵌进 iframe:同站门户(Lax 会话 cookie 会随请求带上)和关登录的部署 都有被点击劫持的可能,也没有「允许谁嵌」的白名单可配。 - 新增 app/web_security.py,纯 ASGI 中间件,main.py 里加在最外层:CORS 预检、登录检查自己回的 401 / 403 也带上; WebSocket 不经过它;接口自己设过的同名头(文件下载的 nosniff)不覆盖、不重复 - frame-ancestors 缺省只有 'self',这时另加 X-Frame-Options: SAMEORIGIN;AGENTLAB_FRAME_ANCESTORS 配了同站门户的白名单时 只发 CSP(XFO 写不了白名单,两个都发会让只认 XFO 的老浏览器把门户也拦掉) - 分享页 /s 只认 AGENTLAB_EMBED_ORIGINS、Referrer-Policy 用 no-referrer。分享链接第 3 波才加,这一波先把配置读进来、 头的规则写好 - 严格的 script-src 先只发 Content-Security-Policy-Report-Only:mount_web 启动时给构建产物 index.html 里的内联主题 脚本算 sha256,只在页面响应上发;接口返回的 HTML(导出的报告自带 meta CSP)不发、不动。观察一个版本没有误报再强制执行 - HSTS 交给终结 https 的反向代理 - 两个白名单只收「协议://主机[:端口]」(可以 *. 开头),带路径、裸 *、没写协议的启动时就报错;deploy/agentlab.env 写明 两个变量和缺省行为 测试 tests/test_web_security.py:页面、/api、中间件自己的 401、CORS 预检都带头;白名单生效时不发 XFO;/s 的规则; 上报策略只在页面上、哈希对得上;已有的头不重复;WebSocket 不受影响;白名单写错启动报错;子进程起真实的 app.main 核对 环境变量接线。
106 lines
4.5 KiB
Python
106 lines
4.5 KiB
Python
"""生产部署:由服务端直接托管前端构建产物(AGENTLAB_WEB_DIST)。
|
||
|
||
开发时页面由 Vite 开发服务器提供、/api 由它转发,用不到这里。生产部署只起一个进程,
|
||
页面和接口同源,省掉 CORS,也不用另配静态服务器。
|
||
|
||
规则:
|
||
- 构建产物里有这个文件就返回它;
|
||
- 其余非 /api 的 GET 回退到 index.html:前端是单页应用,/studio 这类地址由前端路由处理;
|
||
- /api 下不存在的路径照旧是 JSON 404。排除 /api 写在路由的路径规则里,不在处理函数里判断——
|
||
否则 POST /api/不存在 会因为「路径对上了、方法不对」变成 405;
|
||
- 只注册 GET / HEAD 的 HTTP 路由,WebSocket 不经过这里;
|
||
- index.html 不缓存,发版后刷新即生效;assets/ 下的文件名带内容哈希,长缓存;
|
||
- index.html 另带严格 script-src 的上报策略(Content-Security-Policy-Report-Only):启动时按构建产物里的内联脚本算
|
||
sha256(web_security.script_policy),只上报、不拦。其余安全头由 web_security 的中间件统一补。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import mimetypes
|
||
from pathlib import Path
|
||
|
||
from fastapi import FastAPI, HTTPException
|
||
from fastapi.responses import FileResponse
|
||
from starlette.convertors import Convertor, register_url_convertor
|
||
|
||
from app.web_security import REPORT_ONLY_HEADER, script_policy
|
||
|
||
_NO_CACHE = "no-cache"
|
||
_IMMUTABLE = "public, max-age=31536000, immutable"
|
||
|
||
# 精简的 Linux 镜像里没有 /etc/mime.types,mimetypes 的内置表又缺几种(woff2 之类)。
|
||
# 构建产物里出现的类型写死在这里,别的再交给 mimetypes 猜
|
||
_TYPES = {
|
||
".html": "text/html; charset=utf-8",
|
||
".js": "text/javascript; charset=utf-8",
|
||
".mjs": "text/javascript; charset=utf-8",
|
||
".css": "text/css; charset=utf-8",
|
||
".json": "application/json",
|
||
".map": "application/json",
|
||
".svg": "image/svg+xml",
|
||
".png": "image/png",
|
||
".ico": "image/x-icon",
|
||
".woff": "font/woff",
|
||
".woff2": "font/woff2",
|
||
".wasm": "application/wasm",
|
||
".txt": "text/plain; charset=utf-8",
|
||
}
|
||
|
||
|
||
class _WebPath(Convertor[str]):
|
||
"""任意路径,但不以 api 这一段开头:/api、/api/… 留给接口路由和它们自己的 404。"""
|
||
|
||
regex = r"(?!api(?:/|$)).*"
|
||
|
||
def convert(self, value: str) -> str:
|
||
return value
|
||
|
||
def to_string(self, value: str) -> str:
|
||
return value
|
||
|
||
|
||
register_url_convertor("agentlab_web", _WebPath())
|
||
|
||
|
||
def _media_type(path: Path) -> str | None:
|
||
return _TYPES.get(path.suffix.lower()) or mimetypes.guess_type(path.name)[0]
|
||
|
||
|
||
def mount_web(app: FastAPI, dist: Path) -> None:
|
||
"""在 app 上挂页面托管。必须在所有 /api 路由注册之后调用。"""
|
||
root = dist.resolve()
|
||
index = root / "index.html"
|
||
if not index.is_file():
|
||
# 配了却没有产物:启动时就报出来,别等打开页面才看到一个 404
|
||
raise RuntimeError(
|
||
f"AGENTLAB_WEB_DIST 指向的 {root} 下没有 index.html。"
|
||
"先构建前端(cd frontend && pnpm build),或者去掉这个设置"
|
||
)
|
||
|
||
# 哈希按启动这一刻的 index.html 算:换了构建产物要重启服务,哈希才跟着换(只上报不拦,对不上也只多几条上报)
|
||
page_headers = {"Cache-Control": _NO_CACHE, REPORT_ONLY_HEADER: script_policy(index.read_text(encoding="utf-8"))}
|
||
|
||
def respond(target: Path, *, immutable: bool = False) -> FileResponse:
|
||
if target in (index, index.resolve()):
|
||
return FileResponse(target, media_type=_media_type(target), headers=page_headers)
|
||
return FileResponse(
|
||
target,
|
||
media_type=_media_type(target),
|
||
headers={"Cache-Control": _IMMUTABLE if immutable else _NO_CACHE},
|
||
)
|
||
|
||
async def serve(path: str) -> FileResponse:
|
||
if path:
|
||
target = (root / path).resolve()
|
||
# resolve 之后还得在 dist 里面:挡住 %2e%2e 这类穿越和指向外面的软链
|
||
if target.is_file() and target.is_relative_to(root):
|
||
return respond(target, immutable=path.startswith("assets/"))
|
||
if path.startswith("assets/"):
|
||
# 发版后旧页面还在请求旧哈希的文件。回退成 index.html 的话浏览器会把它当脚本
|
||
# 解析、报一个看不懂的 MIME 错误,所以明确给 404
|
||
raise HTTPException(status_code=404)
|
||
return respond(index)
|
||
|
||
app.add_api_route(
|
||
"/{path:agentlab_web}", serve, methods=["GET", "HEAD"], include_in_schema=False,
|
||
)
|