前端安全策略实战指南从CSP到HTTPS再到XSS防护

♫瑞丹 前端 阅读 2,951
赞 32 收藏
二维码
手机扫码查看
反馈

又踩坑了,iframe里fetch被CSP拦得死死的

今天上线前测预发环境,突然发现一个页面的用户头像死活加载不出来。控制台清一色报错:Refused to connect to 'https://jztheme.com/api/user/avatar' because it violates the following Content Security Policy directive: "connect-src 'self'"

前端安全策略实战指南从CSP到HTTPS再到XSS防护

我第一反应是:这接口不是早就配过白名单了吗?赶紧翻部署脚本——没错,connect-src 里确实加了 https://jztheme.com,但还是报错。折腾了半天发现,这个页面不是直接在主域下跑的,而是嵌在一个 iframe 里,而 iframe 的 sandbox 属性还带了 allow-scripts 但没开 allow-same-origin……所以它本质上是个“跨源沙箱 iframe”,连自己的 document.domain 都拿不到,更别说绕过 CSP 去发请求了。

这里我踩了个坑:一直以为只要主页面的 CSP 配对了就行,完全忘了 iframe 自身的执行上下文也是独立的安全边界。而且我们这个 iframe 是由第三方平台动态注入的(他们用的是自建的微前端容器),压根不走我们主站的 HTML 模板,所以它的 CSP header 是他们自己配的,跟我们主站的 policy 完全无关。

试了三种方案,最后选了最土但最稳的那个

一开始想搞点“高大上”的:比如用 postMessage 把请求参数传给父窗口,让父窗口代为 fetch,再把结果传回来。逻辑上没问题,但写起来麻烦,还要处理超时、错误重试、并发队列……而且父窗口得监听一堆 message,还得校验来源 origin,稍不留神就成 XSS 温床。后来试了下发现,如果父窗口也开了 allow-scripts 但没开 allow-same-origin,那 postMessage 还是收不到——因为沙箱 iframe 发出的 message event 的 sourcenull,你连 reply 都没法 reply。

第二个想法是改 iframe 的 src,加个代理路径,比如 https://our-proxy.jztheme.com/forward?url=https%3A%2F%2Fjztheme.com%2Fapi%2Fuser%2Favatar,让后端去转发。但问题来了:我们的网关层不支持任意 URL 透传(防 SSRF),只允许白名单内的 path,比如 /api/v1/avatar 这种固定路由。硬要加的话得改网关策略,上线流程太重,临时搞不定。

最后咬牙上了第三种:不 fetch,改用 <img> 标签加载头像。虽然看起来 low,但胜在简单、可靠、天然绕过 CSP 的 connect-src 限制——因为图片加载走的是 img-src,不是 connect-src。而且我们头像接口本来就是 GET,返回 PNG/JPEG,完全符合 img 标签语义。

核心代码就这几行,但有三个细节必须注意

不是说改成 <img src="xxx"> 就完事了。我们原来的逻辑是先 fetch 拿 JSON,里面有个 avatar_url 字段,再渲染;现在得拆成两步:第一步还是得 fetch 用户基本信息(这部分用的是同源 API,不受影响),第二步才用 img 标签加载头像。但要注意三点:

  • 头像 URL 要带时间戳或随机参数,避免缓存失效:不然用户换了头像,CDN 还在吐旧图。我们加了 ?t=${Date.now()},虽然有点暴力,但比算 ETag 简单多了
  • 必须加 loading 和 error fallback:img 加载失败不会抛 JS 异常,得靠 onerror 手动捕获。我们统一 fallback 到一个灰色占位图
  • 如果头像地址本身是 blob 或 data-url,不能直接塞进 src:我们之前有部分逻辑会把 base64 头像存在 localstorage 里,这时候就得判断协议头,分开处理

最终落地的 React 组件片段长这样(去掉了无关的 state 和 props):

function Avatar({ userId }) {
  const [avatarSrc, setAvatarSrc] = useState('');
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(false);

  useEffect(() => {
    // 先取用户基础信息(同源,CSP 不拦)
    fetch(/api/users/${userId})
      .then(res => res.json())
      .then(data => {
        if (data.avatar_url) {
          // 关键:给 URL 加时间戳,防缓存
          const url = new URL(data.avatar_url);
          url.searchParams.set('t', Date.now().toString());
          setAvatarSrc(url.toString());
        } else {
          setError(true);
        }
      })
      .catch(() => setError(true))
      .finally(() => setLoading(false));
  }, [userId]);

  if (loading) return <div className="skeleton rounded-full w-10 h-10"></div>;
  if (error) return <img src="/images/avatar-placeholder.svg" alt="avatar" className="w-10 h-10 rounded-full" />;

  return (
    <img
      src={avatarSrc}
      alt="user avatar"
      className="w-10 h-10 rounded-full object-cover"
      onError={() => {
        setError(true);
        setLoading(false);
      }}
    />
  );
}

顺手还补了个小优化:在 onError 里把 setLoading(false) 放进去,不然加载失败后骨架屏一直挂着。

顺手检查了下其他地方,果然还有漏网之鱼

改完头像,我顺手 grep 了下项目里所有 fetch(,发现还有两处类似场景:一个是 iframe 里的实时消息轮询(用 fetch + setInterval),另一个是上传文件前的预检请求(HEAD 方法)。这两个都卡在同样的 CSP 问题上。

轮询那个我直接干掉了——改成用父窗口的 EventSource 推送,既省资源又避开了沙箱限制;预检请求那个比较麻烦,因为 HEAD 请求没法用 img 标签模拟。最后妥协方案是:先发一个无害的 GET 请求(比如 /api/upload/ping)来探活,成功了再走真正的上传流程。虽然不够严谨,但线上跑了三天没出问题,先上线再说。

还有一个小尾巴:我们用了 Response.clone() 做日志埋点,结果沙箱 iframe 里 clone 报错,提示 “Failed to execute ‘clone’ on ‘Response’: Response body is not available in opaque response”。查了下是因为跨域 fetch 默认是 mode: 'cors',但沙箱 iframe 下 fetch 跨域响应变成 opaque,body 就拿不到。解决办法是加 mode: 'no-cors',但代价是没法读 response body……最后干脆把那段埋点逻辑挪到了父窗口里做。

踩坑提醒:这三点一定注意

  • CSP 不是主页面配了就万事大吉,iframe 的执行环境有自己的一套安全策略,尤其是带 sandbox
  • postMessage 在沙箱 iframe 里基本等于废的,source 是 null,别指望能双向通信
  • 能用语义化标签(img、script、link)搞定的事,就别硬刚 fetch——它们走的是不同的 CSP 指令,天然规避一部分限制

以上是我踩坑后的总结,希望对你有帮助。如果你有更好的方案,比如怎么优雅地让沙箱 iframe 也能发跨域 fetch(又不降低安全性),欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论