Files
AgentLab/backend/tests/test_web_security.py
ThearchyHelios f759c5c82d W3-d 分享链接(后端):只读、不用登录、必设到期、只显示定时出数的视图
为什么做(04 细案 C2,集成-I6):会话 cookie 是 SameSite=Lax,跨站的 iframe 里带不上,想把看板放进别的门户只能每人开账号且
必须同站;分享链接是跨站嵌入唯一走得通的办法。它把数据交出了授权范围,所以每一步往窄处取:
- view_shares(db/models_apps.ViewShare):只存 key 的 sha256 和开头 6 位;latest 跟着最近一次封存走,fixed 存建分享那一刻
  每个视图的运行 id、按建分享时的应用版本解析;到期必填;打开只计数
- POST / GET / DELETE /api/apps/{id}/shares:要应用的 manage,建要管理员开了 apps.share_links(缺省关);个人应用、没发布、
  归档的不能分享;这一页没有最近封存的视图不收;到期最长 90 天;key 只在建的时候交回一次(/s#<key>);建、撤销写审计
- GET /api/shares/page、/api/shares/report.html 是公开路由(中间件把 /api/shares/ 当公开前缀),只认请求头
  X-AgentLab-Share,地址里带的 key 不认;撤销、到期回 410 写明;开关关掉时已有的一并 403;应用归档、页面没了 410
- apps/resolve.resolve_page 加 share 旁路:只解析 latest_sealed 视图(fixed 用存下的运行,趋势也停在那一期),按需、实例、
  探索视图写明原因、从不发起运行(auto 的也不);流程组件和报告换成写明原因的说明块;页面缓存键另加分享的标识;out 另给
  各视图取到的运行和可下载的报告
- 下载报告只认这条分享此刻解析出来的运行,内部就是 export_report_html(同样遮过、照常记导出审计,导出人记成不冒充任何人的
  分享身份)
- 分享接口都不放给访问密钥(不在 KEY_ROUTES,门禁 403)

安全头:/s 用 AGENTLAB_EMBED_ORIGINS 和 no-referrer(第 1 波 web_security 预留的规则,这次在真实页面托管下测)。C3 同站门户
嵌整站只做配置和文档:写进 web_security 的模块说明和 deploy/agentlab.env 的「嵌入」一节,「分享链接还没有上线」那句改掉。
2026-10-16 16:30:42 +08:00

236 lines
11 KiB
Python
Raw Permalink 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.
"""安全响应头(04 细案 C1):app/web_security.py 的纯 ASGI 中间件,加上 mount_web 给页面算内联脚本的哈希。
要守住的:
- 页面、/api 的响应(连同登录检查自己回的 401)都带 nosniff、Referrer-Policy 和 CSP frame-ancestors;
- frame-ancestors 缺省 'self',只有 'self' 时另加 X-Frame-Options: SAMEORIGIN;配了 AGENTLAB_FRAME_ANCESTORS 白名单时
以 CSP 为准、不发 XFO(XFO 写不了白名单,两个都发的话老浏览器按 XFO 把白名单里的门户也拦了);
- 分享页 /s 用 AGENTLAB_EMBED_ORIGINS、Referrer-Policy: no-referrer(真实页面托管下的头另见 test_shares.py);
- 严格的 script-src 只发 Content-Security-Policy-Report-Only,只在页面(index.html)上发,哈希就是构建产物里内联
脚本的 sha256;接口返回的 HTML(导出的报告自带 meta CSP)不带它;
- 接口自己设过的同名头不覆盖、不重复;WebSocket 不经过它;
- 白名单写错了启动时就报错。
"""
from __future__ import annotations
import base64
import hashlib
import json
import os
import subprocess
import sys
from pathlib import Path
import httpx
import pytest
from fastapi import APIRouter, FastAPI, WebSocket
from fastapi.responses import HTMLResponse, Response
from fastapi.testclient import TestClient
from pydantic import ValidationError
from app.core.config import BACKEND_DIR, PROJECT_DIR, Settings
from app.main import app as main_app
from app.web_security import SecurityHeaders
from tests.fakes import auth_env
THEME = "\n try { document.documentElement.setAttribute('data-theme', 'light') } catch (e) {}\n "
INDEX = ("<!doctype html><html><head><script>" + THEME + "</script>"
"<script type=\"module\" crossorigin src=\"/assets/index-AbC123.js\"></script></head>"
"<body><div id=root></div></body></html>")
HASH = base64.b64encode(hashlib.sha256(THEME.encode()).digest()).decode()
REPORT = ("<!doctype html><meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'\">"
"<script>console.log('report')</script>")
@pytest.fixture
def dist(tmp_path: Path) -> Path:
root = tmp_path / "dist"
(root / "assets").mkdir(parents=True)
(root / "index.html").write_text(INDEX)
(root / "assets" / "index-AbC123.js").write_text("console.log('bundle')")
return root
def _app(dist: Path | None = None, **config) -> FastAPI:
app = FastAPI()
api = APIRouter(prefix="/api")
@api.get("/ping")
async def ping() -> dict[str, str]:
return {"pong": "1"}
@api.get("/report.html")
async def report() -> HTMLResponse:
return HTMLResponse(REPORT)
@api.get("/download")
async def download() -> Response:
# 文件下载自己设了 nosniff(api/files.py):不能再加一份
return Response(b"x", headers={"X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer"})
@api.websocket("/echo")
async def echo(ws: WebSocket) -> None:
await ws.accept()
await ws.send_text(await ws.receive_text())
await ws.close()
app.include_router(api)
if dist is not None:
from app.web import mount_web
mount_web(app, dist)
app.add_middleware(SecurityHeaders, **config)
return app
async def _get(app: FastAPI, path: str) -> httpx.Response:
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://t") as client:
return await client.get(path)
def _single(r: httpx.Response, name: str) -> str | None:
values = r.headers.get_list(name)
assert len(values) <= 1, f"{name} 发了 {len(values)} 份:{values}"
return values[0] if values else None
async def test_api_and_page_carry_the_defaults(dist: Path) -> None:
app = _app(dist, frame_ancestors=[], embed_origins=[])
for path in ("/api/ping", "/api/nope", "/", "/studio", "/assets/index-AbC123.js"):
r = await _get(app, path)
assert _single(r, "x-content-type-options") == "nosniff", path
assert _single(r, "referrer-policy") == "strict-origin-when-cross-origin", path
assert _single(r, "content-security-policy") == "frame-ancestors 'self'", path
assert _single(r, "x-frame-options") == "SAMEORIGIN", path
async def test_report_only_script_policy_is_only_on_the_page(dist: Path) -> None:
app = _app(dist, frame_ancestors=[], embed_origins=[])
for path in ("/", "/studio", "/runs/abc/evidence"):
r = await _get(app, path)
assert r.text == INDEX
assert _single(r, "content-security-policy-report-only") == f"script-src 'self' 'sha256-{HASH}'", path
# 脚本文件、接口不带;接口返回的 HTML(导出的报告)自带 meta CSP,不加一份对不上哈希的上报策略,正文原样
for path in ("/assets/index-AbC123.js", "/api/ping", "/api/report.html"):
r = await _get(app, path)
assert "content-security-policy-report-only" not in r.headers, path
r = await _get(app, "/api/report.html")
assert r.text == REPORT
async def test_whitelist_wins_and_drops_x_frame_options() -> None:
app = _app(frame_ancestors=["https://portal.example.com", "https://*.corp.example"], embed_origins=[])
r = await _get(app, "/api/ping")
assert _single(r, "content-security-policy") == \
"frame-ancestors 'self' https://portal.example.com https://*.corp.example"
assert "x-frame-options" not in r.headers, "有白名单时以 CSP 为准:XFO 写不了白名单"
async def test_share_page_uses_the_embed_origins(dist: Path) -> None:
app = _app(dist, frame_ancestors=["https://portal.example.com"], embed_origins=["https://bi.example.org"])
for path in ("/s", "/s/", "/s/anything"):
r = await _get(app, path)
assert _single(r, "content-security-policy") == "frame-ancestors 'self' https://bi.example.org", path
assert _single(r, "referrer-policy") == "no-referrer", path
assert "x-frame-options" not in r.headers, path
# 只认 /s 这一段:/settings、/studio 照整站的规则
for path in ("/settings", "/studio", "/share"):
r = await _get(app, path)
assert _single(r, "content-security-policy") == "frame-ancestors 'self' https://portal.example.com", path
assert _single(r, "referrer-policy") == "strict-origin-when-cross-origin", path
# 没配嵌入白名单:分享页只能被本站嵌,同样另加 XFO
r = await _get(_app(dist, frame_ancestors=[], embed_origins=[]), "/s")
assert _single(r, "content-security-policy") == "frame-ancestors 'self'"
assert _single(r, "x-frame-options") == "SAMEORIGIN"
async def test_headers_already_set_are_kept() -> None:
r = await _get(_app(frame_ancestors=[], embed_origins=[]), "/api/download")
assert _single(r, "x-content-type-options") == "nosniff"
assert _single(r, "referrer-policy") == "no-referrer"
assert _single(r, "content-security-policy") == "frame-ancestors 'self'"
def test_websocket_is_not_touched() -> None:
with TestClient(_app(frame_ancestors=[], embed_origins=[])) as client:
with client.websocket_connect("/api/echo") as ws:
ws.send_text("hi")
assert ws.receive_text() == "hi"
async def test_main_app_and_its_own_refusals_carry_the_headers() -> None:
"""真实的 app.main:加在最外层,登录检查自己回的 401、CORS 预检也带。"""
app = main_app
r = await _get(app, "/api/health")
assert _single(r, "content-security-policy") == "frame-ancestors 'self'"
assert _single(r, "x-frame-options") == "SAMEORIGIN" and _single(r, "x-content-type-options") == "nosniff"
await auth_env.reset()
try:
await auth_env.turn_on_local()
r = await _get(app, "/api/workflows")
assert r.status_code == 401 and r.json()["code"] == "auth_required"
assert _single(r, "x-content-type-options") == "nosniff"
assert _single(r, "content-security-policy") == "frame-ancestors 'self'"
finally:
await auth_env.reset()
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://t") as client:
r = await client.options("/api/health", headers={"Origin": "http://localhost:5273",
"Access-Control-Request-Method": "GET"})
assert r.status_code == 200 and _single(r, "x-content-type-options") == "nosniff"
def test_origin_lists_parse_and_bad_entries_fail_at_startup(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("AGENTLAB_FRAME_ANCESTORS", raising=False)
monkeypatch.delenv("AGENTLAB_EMBED_ORIGINS", raising=False)
assert Settings().frame_ancestors == [] and Settings().embed_origins == []
monkeypatch.setenv("AGENTLAB_FRAME_ANCESTORS", " https://portal.example.com/ , https://*.corp.example,'self'")
monkeypatch.setenv("AGENTLAB_EMBED_ORIGINS", '["http://bi.example.org:8080"]')
got = Settings()
assert got.frame_ancestors == ["https://portal.example.com", "https://*.corp.example"], "末尾的 / 去掉,'self' 本来就有"
assert got.embed_origins == ["http://bi.example.org:8080"]
monkeypatch.delenv("AGENTLAB_EMBED_ORIGINS")
# 只收「协议://主机[:端口]」:路径、通配一切、没写协议、'none' 这类写法启动时就报出来,别到嵌入失败才发现
for bad in ("*", "portal.example.com", "https://portal.example.com/app", "ftp://portal.example.com", "'none'",
"https://", "https://portal.example.com?x=1"):
monkeypatch.setenv("AGENTLAB_FRAME_ANCESTORS", bad)
with pytest.raises(ValidationError, match="AGENTLAB_FRAME_ANCESTORS"):
Settings()
def test_deploy_env_documents_both_lists() -> None:
text = (PROJECT_DIR / "deploy" / "agentlab.env").read_text()
assert "# AGENTLAB_FRAME_ANCESTORS=" in text and "# AGENTLAB_EMBED_ORIGINS=" in text
_PROBE = r"""
import asyncio, json, httpx
from app.main import app
async def main():
t = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=t, base_url="http://t") as c:
out = {}
for p in ("/", "/api/health"):
r = await c.get(p)
out[p] = {k: r.headers.get(k) for k in ("content-security-policy", "x-frame-options",
"content-security-policy-report-only")}
print(json.dumps(out))
asyncio.run(main())
"""
def test_main_app_reads_the_whitelist_and_hashes_the_page(dist: Path, tmp_path: Path) -> None:
"""真实的 app.main:环境变量里的白名单生效、不发 XFO;托管页面时 index.html 带上报策略。settings、app 都是模块级
单例,起一个子进程导入。"""
env = {**os.environ, "AGENTLAB_DATA_DIR": str(tmp_path / "data"), "AGENTLAB_WEB_DIST": str(dist),
"AGENTLAB_SECRET_KEY": "test-only-not-a-real-key", "AGENTLAB_FRAME_ANCESTORS": "https://portal.example.com"}
proc = subprocess.run([sys.executable, "-c", _PROBE], cwd=BACKEND_DIR, env=env, capture_output=True, text=True,
timeout=120)
assert proc.returncode == 0, proc.stderr[-2000:]
out = json.loads(proc.stdout.strip().splitlines()[-1])
for path in ("/", "/api/health"):
assert out[path]["content-security-policy"] == "frame-ancestors 'self' https://portal.example.com", path
assert out[path]["x-frame-options"] is None, path
assert out["/"]["content-security-policy-report-only"] == f"script-src 'self' 'sha256-{HASH}'"
assert out["/api/health"]["content-security-policy-report-only"] is None