Files
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

100 lines
6.2 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):页面不能被别的站点套进 iframe 诱导点击,浏览器不按内容猜类型,链接不把完整地址带给别的站点。
以前服务端一个安全头都不发:任何站点都能把 AgentLab 嵌进 iframe。同站的门户页面(SameSite=Lax 的会话 cookie 会随请求
带上)和关登录的部署,都有被点击劫持的可能;也没有一条「允许谁嵌」的白名单可配。
纯 ASGI 中间件(同 auth/middleware.py,main.py 里加在最外层):在 http.response.start 上补头,WebSocket 不经过它。
所有 HTTP 响应(页面、/api、登录检查自己回的 401 / 403、CORS 预检)都补:
- X-Content-Type-Options: nosniff;
- Referrer-Policy: strict-origin-when-cross-origin(分享页 /s 用 no-referrer:分享的 key 在地址的 # 后面,本来不随
Referer 走,这里再收一道);
- Content-Security-Policy: frame-ancestors。缺省只有 'self';AGENTLAB_FRAME_ANCESTORS 列出允许嵌整站的同站门户;分享页
/s 只认 AGENTLAB_EMBED_ORIGINS(分享链接,api/shares.py;页面是前端不进外壳的那一页,由 mount_web 照常托管);
- 只有 frame-ancestors 是 'self' 时另加 X-Frame-Options: SAMEORIGIN,给不认 CSP 的老浏览器。XFO 写不了白名单,有白名单
时只发 CSP:两个都发的话,只认 XFO 的老浏览器会把白名单里的门户也拦掉(认 CSP 的浏览器以 frame-ancestors 为准)。
接口自己设过的同名头(文件下载的 nosniff)不覆盖、不重复。
严格的 script-src 先只上报、不拦(Content-Security-Policy-Report-Only):页面里有一段首帧前定主题的内联脚本,强制执行
的话写错一个哈希页面就会先闪一帧深色,构建会不会产出别的内联内容也没核实。所以 mount_web(app/web.py)启动时给构建产物
index.html 里的内联脚本算 sha256(script_policy),只在页面这一个响应上发;观察一个版本没有误报,再改成强制执行。没配
上报地址:违规只在浏览器的控制台里看得到。接口返回的 HTML(导出的报告、可验证报告)文件里自带 meta CSP,不发它,也不动。
嵌入的两种办法(04 细案 C3,只做配置和文档):
- 同站门户嵌整站:门户和 AgentLab 在同一个可注册域名下(portal.corp.example 和 agentlab.corp.example)时,把门户的源写进
AGENTLAB_FRAME_ANCESTORS。会话 cookie 是 SameSite=Lax,同站的 iframe 里会随请求带上:看的人照常登录、单点登录照常,权限
和直接打开一样。不另做嵌入票据;
- 跨站嵌:嵌整站明确不支持——跨站的 iframe 里带不上 Lax 的会话 cookie,登录不了;改成 SameSite=None 会削弱整站防跨站请求
伪造的那一层,换来的嵌入在 Safari、Firefox 上默认仍然被拦。跨站要嵌,用分享链接(只读、必设到期、可撤销,/s 只认
AGENTLAB_EMBED_ORIGINS)。
deploy/agentlab.env 的「嵌入」一节写的是同一套。
HSTS 交给终结 https 的反向代理:这里不知道对外是不是 https、要不要连子域名一起管,发错了浏览器会按 max-age 记很久,
撤不回来。
"""
from __future__ import annotations
import base64
import hashlib
import re
from typing import Any
#: 分享页的路径(/s#<分享 key>):只认 /s 这一段,/settings、/studio 不算
SHARE_PATH = "/s"
_SELF = "'self'"
_DEFAULT_REFERRER = b"strict-origin-when-cross-origin"
_SHARE_REFERRER = b"no-referrer"
#: 页面里不带 src 的 <script>:要算哈希的内联脚本
_INLINE_SCRIPT = re.compile(r"<script\b(?![^>]*\bsrc\s*=)[^>]*>(.*?)</script\s*>", re.IGNORECASE | re.DOTALL)
REPORT_ONLY_HEADER = "Content-Security-Policy-Report-Only"
def is_share_path(path: str) -> bool:
return path == SHARE_PATH or path.startswith(SHARE_PATH + "/")
def frame_policy(origins: list[str]) -> tuple[bytes, bool]:
"""(CSP 的值, 要不要另加 X-Frame-Options)。origins 是白名单(config 里校验过、去掉了 'self')。"""
sources = [_SELF, *[o for o in origins if o != _SELF]]
return f"frame-ancestors {' '.join(sources)}".encode(), len(sources) == 1
def script_policy(html: str) -> str:
"""页面的严格 script-src:本站的脚本文件('self')加上页面里每段内联脚本的 sha256(按原文的 UTF-8 字节算,同浏览器)。"""
hashes = [base64.b64encode(hashlib.sha256(body.encode()).digest()).decode()
for body in _INLINE_SCRIPT.findall(html)]
return " ".join(["script-src", _SELF, *(f"'sha256-{h}'" for h in dict.fromkeys(hashes))])
class SecurityHeaders:
"""frame_ancestors / embed_origins 不传时取 core/config 的 AGENTLAB_FRAME_ANCESTORS、AGENTLAB_EMBED_ORIGINS(测试传别的)。"""
def __init__(self, app: Any, *, frame_ancestors: list[str] | None = None,
embed_origins: list[str] | None = None) -> None:
from app.core.config import settings
self.app = app
self.site = frame_policy(settings.frame_ancestors if frame_ancestors is None else frame_ancestors)
self.share = frame_policy(settings.embed_origins if embed_origins is None else embed_origins)
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
share = is_share_path(scope.get("path") or "")
frame, xfo = self.share if share else self.site
wanted = [(b"x-content-type-options", b"nosniff"),
(b"referrer-policy", _SHARE_REFERRER if share else _DEFAULT_REFERRER),
(b"content-security-policy", frame)]
if xfo:
wanted.append((b"x-frame-options", b"SAMEORIGIN"))
async def send_with_headers(message: dict[str, Any]) -> None:
if message["type"] == "http.response.start":
headers = list(message.get("headers") or [])
present = {name.lower() for name, _ in headers}
headers += [(name, value) for name, value in wanted if name not in present]
message = {**message, "headers": headers}
await send(message)
await self.app(scope, receive, send_with_headers)