Files
ephron-ren-prd/prd-canvas-iframe-csp-fix.md

4.6 KiB
Raw Permalink Blame History

PRD: Canvas iframe 嵌入被安全策略阻止

问题描述

Canvas 服务的 /view/{slug} 页面通过 iframe 嵌入 /raw/{slug} 来展示 Canvas 内容。但由于共享安全头中间件设置了以下策略,浏览器会阻止 iframe 加载:

  • X-Frame-Options: DENY — 禁止被任何页面 iframe 嵌入
  • frame-ancestors 'none' — CSP 禁止被任何页面嵌入

结果: 用户访问 canvas.ephron.ren/view/hermes-agent-aiiframe 区域显示空白或"拒绝连接"。

影响范围

  • 所有 Canvas 页面的预览功能完全失效
  • 首页卡片的缩略图预览也无法加载

根因分析

shared/security_headers.py 中定义了全局安全头策略:

_CSP_POLICY = (
    ...
    "frame-ancestors 'none'; "  # 第17行
    ...
)

# 第39行
response.headers.setdefault("X-Frame-Options", "DENY")

这个策略被所有 ephron.ren 服务共享Auth、Blog、Canvas、Prompt但 Canvas 服务的 /raw/{slug} 路由需要被同源 iframe 嵌入。

修复方案

方案A修改 /raw/{slug} 路由(推荐)

canvas/src/routes/pages.pyraw_canvas 函数中,返回响应时覆盖安全头:

@router.get("/raw/{slug}", response_class=HTMLResponse)
async def raw_canvas(
    slug: str,
    ephron_auth: str | None = Cookie(default=None),
):
    # ... 现有代码 ...
    
    # Build CSP that allows same-origin iframe embedding
    raw_csp = (
        "default-src 'self'; "
        "script-src 'self' 'unsafe-inline'; "
        "script-src-elem 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
        "style-src 'self' 'unsafe-inline'; "
        "style-src-elem 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net https://maxcdn.bootstrapcdn.com; "
        "img-src 'self' data: https:; "
        "font-src 'self' data: https://fonts.gstatic.com https:; "
        "connect-src 'self'; "
        "frame-ancestors 'self'; "  # Allow same-origin embedding
        "base-uri 'self'; "
        "form-action 'self' https://*.ephron.ren"
    )
    
    return Response(
        content=canvas.content_html,
        media_type="text/html; charset=utf-8",
        headers={
            "X-Frame-Options": "SAMEORIGIN",  # Override DENY for iframe embedding
            "Content-Security-Policy": raw_csp,
        },
    )

优点:

  • 最精准,只影响需要被嵌入的路径
  • 不影响其他服务的安全策略
  • 改动范围最小

缺点:

  • 需要在路由层面重复 CSP 策略

方案B修改共享中间件添加路径例外

shared/security_headers.py 中添加例外路径:

    "/raw/",  # Canvas raw content needs iframe embedding
})

@app.middleware("http")
async def _security_headers(request: Request, call_next):
    response = await call_next(request)
    response.headers.setdefault("X-Content-Type-Options", "nosniff")
    
    # Check if path is exempt from DENY policy
    path = request.url.path
    if any(path.startswith(p) for p in _EXEMPT_PATHS):
        response.headers.setdefault("X-Frame-Options", "SAMEORIGIN")
        # Use modified CSP with frame-ancestors 'self'
        csp = _CSP_POLICY.replace("frame-ancestors 'none'", "frame-ancestors 'self'")
        response.headers.setdefault("Content-Security-Policy", csp)
    else:
        response.headers.setdefault("X-Frame-Options", "DENY")
        response.headers.setdefault("Content-Security-Policy", _CSP_POLICY)
    
    # ... 其他代码 ...

优点:

  • 集中管理,易于维护
  • 其他服务如果需要 iframe 嵌入也能受益

缺点:

  • 修改共享代码,影响所有服务
  • 需要更仔细的测试

推荐方案

推荐方案A,原因:

  1. 改动范围最小,只修改 Canvas 服务的一个路由
  2. 最精准,只影响 /raw/{slug} 路径
  3. 不影响其他服务的安全策略
  4. 风险最低

验证方法

修复后验证:

  1. 访问 https://canvas.ephron.ren/view/hermes-agent-ai
  2. 检查 iframe 是否正常加载内容
  3. 检查浏览器控制台是否有 CSP 错误
  4. 验证其他页面(首页、管理页)是否正常
# 检查响应头
curl -sI "https://canvas.ephron.ren/raw/hermes-agent-ai" | grep -E "x-frame-options|content-security-policy"

期望输出:

x-frame-options: SAMEORIGIN
content-security-policy: ... frame-ancestors 'self'; ...

相关文件

  • shared/security_headers.py — 共享安全头中间件
  • canvas/src/routes/pages.py — Canvas 页面路由(raw_canvas 函数)
  • canvas/src/main.py — Canvas 服务入口(安装安全头中间件)

标签

  • bug
  • security
  • canvas
  • iframe
  • csp