个人站跑到有一定流量,早晚会碰到「浏览器显示不安全」「HTTPS 打开要等三秒」「打包成 App 之后请求全挂」这类问题。往下挖,十有八九不是证书过期、也不是配置写错,而是同一个浏览器安全机制在起作用:混合内容(Mixed Content)拦截。它安静、不报错、只在控制台留几行黄字,用户端看到的就是「图裂了」「样式乱了」「按钮点不动」。这篇文章把它从判定规则讲到落地整改,尽量讲清每一步为什么这么做。
一、先分清主动与被动:哪些资源被拦,哪些只被警告
浏览器把 HTTPS 页面里加载的 HTTP 子资源分成两类处理。被动型(passive)包括图片、音视频、img 的 srcset、video 的 poster、audio、以及 CSS 里的 url() 背景图。浏览器早期对它们只升级、只警告,现在 Chrome 与 Edge 的做法是统一自动升级到 https 再请求,升不上去就失败。
主动型(active)包括脚本、样式表、iframe、XHR 与 fetch、WebSocket、字体文件。这一类会被直接阻断,不做任何尝试。原因很直白:一个 HTTP 加载的脚本运行在 HTTPS 页面的源里,攻击者可以在传输途中改写它,等于整页失守;样式表能通过 CSS 选择器泄密;字体能被用来做指纹与隐写。
这个区分决定了整改的优先级。一次全站扫描里,如果主动型只剩个位数,说明主体已经干净,剩下的多半是历史文章里的外链图片,慢慢清即可。反过来,如果主动型有几十条,说明模板层还有硬编码的 http://,得先从模板改。
二、升级不安全请求:一行头解决大部分问题,但有边界
2015 年后主流浏览器支持 upgrade-insecure-requests 指令,可以放在 CSP 里,也可以写成独立响应头:
add_header Content-Security-Policy "upgrade-insecure-requests" always;它的语义是:页面上所有 http:// 的子资源请求,在发出之前先把协议改写成 https://。注意是「改写在浏览器端」,不是「301 一次」。所以服务器必须真的能用 https 服务这些资源,否则就是 404。
三个边界必须清楚。第一,它对 <meta> 标签里的 http-equiv 无效,也对 Navigation 无效,只作用于子资源。第二,它对显式写死的端口号无效,比如 http://example.com:8080/a.js 会保持原样然后被拦。第三,它不修正域名的证书问题,如果那个第三方域名本身没配 https 证书,升级后就是连接失败,比原来的「被拦」更难看——用户看到的是资源加载失败而不是被拦截提示。
所以正确的用法是:先打开这个指令,再去看日志里哪些请求变成了 404 或连接错误,那些就是「该换源、该下掉、或者该走自建代理」的清单。这个做法比逐个 grep 模板里的 http:// 高效得多,因为运行时的真实请求才是完整集合。
三、内容安全策略的升级比头指令更强,但更容易误伤
如果站点已经在用 CSP,可以单独加一条 block-all-mixed-content,或者直接依赖各指令里的 https: 来源白名单。前者是本文章开头说的「一律阻断」,后者是「只允许列表里的来源」。
这里有个常见误判:很多人以为写了 default-src 'self' 就自然把 http 子资源拦住了,其实不一定。浏览器对 'self' 的解析会考虑同源,但如果页面本身通过 http 被访问(比如某个子域还没上证书),'self' 就包含 http 源。这也是为什么在老站上经常看到「主站 HTTPS 正常、某个旧子域仍然 http 且能加载脚本」的怪现象。
实操建议是分两步走:第一周只上 Content-Security-Policy-Report-Only,把违规上报到一个自建接收端点(几十行的 PHP 或 Python 脚本即可),收集一周真实数据;第二周按上报结果收紧,把 upgrade-insecure-requests 与白名单一起上。不要一上来就开强制模式,头部指令一旦生效,出问题的是用户而不是你。
四、https->http 的 301 反向配置:最容易忽略的一处
清理混合内容时,很多人的思路是「所有 http 都重定向到 https」。对页面导航这是对的,但对子资源是个陷阱。假设某第三方 SDK 通过 http 引用一个静态文件,你在源站把 http 全部 301 到 https,浏览器会跟着 301 走完,最终仍然拿到资源——表面上问题解决了,实际上多了一次跨域重定向,首屏时间白涨 100~300 毫秒,并且在 CORS 场景下还多一次预检。
更严重的情况是对方域名不支持 https 却在 http 侧做了 301 到别的域名,结果就是资源彻底拿不到。这类问题在日志里的表现是「301 后没有对应 200」,因此查混合内容不能只看源站 access log,还要看上游 CDN 的回源日志与浏览器 DevTools 的 Network 面板(按 Status 排序,找 301/302 后跟 4xx 的链条)。
五、用一份可复用的扫描脚本把清单打出来
浏览器控制台只显示当前页面,全站几千篇文章没法一页页点。比较省事的做法是抓 sitemap 里的 URL 列表,逐页抓 HTML,用正则抽出所有 src、href、url() 里的 http 地址,再按主动/被动分类统计。抓取时带上自定义 User-Agent 并且在 robots 允许的范围内,控制并发到 1~2,别把自己的站打挂。
curl -s https://www.example.com/sitemap.xml \
| grep -oE '<loc>[^<]+' | sed 's|<loc>||' > urls.txt
while read -r u; do
curl -s -A "mixed-content-audit/1.0" --max-time 15 "$u"
done < urls.txt > corpus.html拿到合并后的 HTML,用一次 Python 统计就够:
import re, collections
html = open('corpus.html', encoding='utf-8', errors='replace').read()
urls = re.findall(r'https?://[^\s\x27\x22<>\x29]+', html)
bad = [u for u in urls if u.startswith('http://')]
c = collections.Counter(re.sub(r'^(https?://[^/]+).*', r'\1', u) for u in bad)
for host, n in c.most_common(20):
print(n, host)输出的域名排行就是整改清单:排第一的通常是图床或旧 CDN,排第二第三的多半是历史文章里嵌的第三方统计脚本或视频外链。按域名批量处理,比按文章处理快一个数量级。
六、字体与样式表:被忽略的两类高频违规
字体文件是主动型资源,而且经常来自第三方字体服务。很多字体服务在 @font-face 里给出的地址是 http,或者虽然给了 https,但 CSS 里的 src 又写了一个 http 的备用地址。整改方式是自己代理:把字体文件下载到本站 /static/fonts/,用本站域名引用,顺便把子集化做掉。这既解决了混合内容,也解决了第三方域名解析与 Cookie 的额外开销。
样式表的情况类似但更隐蔽。后台编辑器、第三方评论系统、广告 SDK 插入的 <link rel="stylesheet" href="http://..."> 往往在正文里,不在模板里。这类只能靠上面那份全站扫描清单逐条处理,处理方式是改写正文里的 URL(批量 UPDATE 时务必先备份那一列),而不是在模板层做替换——模板替换改不了数据库里的历史内容。
七、iframe 与用户上传内容:两个无法用头解决的场景
iframe 里的内容由第三方页面自己决定加载什么,父页面的 CSP 管不到里面。能做的只有两件事:给 iframe 加 sandbox,以及在父页面的 CSP 里用 frame-src 限制只允许 https 来源。如果必须嵌入一个只有 http 的老服务,唯一干净的方案是自己在同源下做反向代理,让它对外永远是 https。
用户上传内容包括富文本里的外链图片与视频。这类不能指望「人工清理干净」,正确做法是在渲染层加一层过滤:输出正文前,用正则或 DOM 解析把所有 http:// 的资源地址改写成 https://(如果目标域名支持),不支持的改成一个统一的占位图并加 rel="nofollow noopener"。渲染层过滤的好处是即时生效、不动数据库、可随时回滚。
八、验证:三个必须看的判据
整改完别只看「页面能打开」。三个判据缺一不可。第一,DevTools 的 Console 面板里不应再有 Mixed Content 字样,这个可以在控制台输入筛选;第二,Network 面板按协议排序,确认关键资源(CSS、JS、字体、首图)全部是 h2 或 h3;第三,用 curl -sI 检查响应头里 Content-Security-Policy 确实带上了 upgrade-insecure-requests,而不是只写在 nginx 配置文件里没生效——add_header 在 location 块里会覆盖父级的同名头,这是最常见的「配了但没生效」。
最后提醒一点:混合内容清理不是一次性任务。每次接入新的第三方服务、每次在文章里嵌外链,都可能把 http 带回来。把它做成发布前的检查项(比如在 CI 里跑一遍上面那个扫描脚本,发现新增的 http 域名就失败),比事后救火省事得多。一个人的站,最贵的是注意力,能让机器盯的事就别让自己盯。