Files
AgentLab/backend/app/web.py
T
ThearchyHelios a05efbf27a 安全响应头:所有 HTTP 响应补 nosniff、Referrer-Policy 和 CSP frame-ancestors,页面只上报严格的 script-src
以前服务端一个安全头都不发,任何站点都能把 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 核对
环境变量接线。
2026-10-16 10:17:37 +08:00

106 lines
4.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""生产部署:由服务端直接托管前端构建产物(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,
)