小锁图标消失了,访问量跟着掉了
给站点上了 HTTPS 之后,很多站长会觉得「安全这块搞定了」,直到某天发现浏览器地址栏的小锁图标变成了「不安全」的感叹号,或者干脆带上一行红色警告。更麻烦的是,有些页面在 Chrome 里完全正常,在 Safari 或手机端却出现图片不显示、样式错乱、按钮点了没反应的情况。打开控制台,满屏都是这样的报错:
Mixed Content: The page at 'https://www.example.com/' was loaded over HTTPS,
but requested an insecure image 'http://cdn.example.com/banner.png'.
This request has been blocked. The content must be served over HTTPS.
Mixed Content: The page at 'https://www.example.com/' was loaded over HTTPS,
but requested an insecure stylesheet 'http://example.com/css/main.css'.
This request has been blocked.这就是 HTTPS 混合内容(Mixed Content)问题:页面本身通过 HTTPS 加载,但页面内部又引用了通过 HTTP 加载的资源。浏览器对这种「一半加密一半明文」的状态非常警惕,因为明文加载的资源可以被中间人篡改,一旦被注入恶意脚本,整个 HTTPS 的安全性就形同虚设——攻击者不需要破解 TLS,直接改你那个 HTTP 资源就行了。
这篇文章讲清楚浏览器对混合内容的分级处理策略、怎么一次性把全站的 HTTP 引用找出来、在 Nginx 层如何强制修正,以及几个容易被忽略的隐蔽来源。
浏览器其实是分两级处理的
不是所有混合内容都会被浏览器一刀切拦死。主流浏览器把它分成两类,处理力度不同,理解这个区别对排查很关键。
被动混合内容(Passive / Display Mixed Content):图片、视频、音频、字体这类不会主动执行代码的资源。浏览器默认允许加载,但会在地址栏降级显示安全标识——锁图标变成带感叹号的样式,或者显示「不安全」。这就是为什么有些站点的图片明明能显示,安全标识却没了。用户看到锁消失,信任度立刻下降,对转化率的影响非常直接。
主动混合内容(Active Mixed Content):脚本(<script>)、样式表(<link rel="stylesheet">)、iframe、XHR/fetch 请求、字体文件中通过 CSS 加载的部分等。这类资源能直接操控页面,浏览器直接阻断加载,不做任何妥协。所以你会看到页面样式完全崩掉、JS 功能全废,控制台报 blocked。
还有一类更容易被忽略的:表单提交到 HTTP 地址。用户在 HTTPS 页面填了密码、邮箱,点击提交时浏览器会弹出「你即将在不安全的连接上提交信息」的红色警告,这是转化率杀手。所有表单 action 都必须是 HTTPS。
另外要建立预期:这不是浏览器 bug,也不会随时间放宽。相反,标准在持续收紧。Chrome 从 90 版本起对混合内容做专门提示,Safari 更早就在地址栏直接标红。随着 HTTPS 成为默认,混合内容的惩罚只会越来越重,早修复早受益。
第一步:把全站的 HTTP 引用揪出来
手工翻源码是不现实的,尤其是 WordPress 这类数据库驱动的站点,大量链接藏在文章内容和配置表里。正确做法是分三层扫描:静态文件层、渲染后的 HTML 层、数据库层。
静态文件扫描
针对主题模板、JS、CSS 文件,直接搜索写死的 HTTP 地址:
cd /var/www/html
# 找出所有含 http:// 的静态引用(排除注释与合法外链)
grep -rIn --include='*.php' --include='*.html' --include='*.js' --include='*.css' \
-E 'https?://[^"'"'"' ]+' . \
| grep -v 'https://' \
| grep -v 'w3.org\|schema.org\|gmpg.org\|creativecommons' \
| head -50注意要排除 XML 命名空间声明(xmlns)和结构化数据的 schema 地址,这些是合法的标识符而非真实请求地址,改了反而会出错。
渲染后 HTML 扫描(最准确)
真正要处理的是浏览器实际收到的 HTML,因为很多 HTTP 地址是 PHP 运行时拼接出来的。直接抓页面源码分析,比翻文件更可靠:
#!/bin/bash
# /usr/local/bin/mixed_content_scan.sh
# 扫描站点页面中的 http:// 资源引用
SITE="$1"
UA="Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0"
# 抓取页面(带时间戳绕缓存)
html=$(curl -sL -A "$UA" "${SITE}/?mc=$(date +%s)")
echo "=== 主动混合内容(会被阻断,必须修) ==="
echo "$html" | grep -oE '(src|href)="http://[^"]+"' | sort -u
echo ""
echo "=== 被动混合内容(锁图标降级,建议修) ==="
echo "$html" | grep -oE 'http://[^"'"'"' ]+\.(jpg|jpeg|png|gif|webp|svg|mp4|woff2?|ttf)' | sort -u把这段脚本存成文件执行,对你站点的几个主要页面(首页、文章页、分类页)分别跑一遍,把所有出现的 http 地址汇总成清单。优先处理清单里出现次数最多的那几个,往往是公共的 JS、CSS 或 CDN 地址,改一处能解决一大片。
数据库扫描(WordPress / Typecho 必做)
这类 CMS 的文章正文、站点设置、小工具配置都存在数据库里,模板文件是干净的但内容里带着旧地址。以 WordPress 为例:
# 先备份!任何直接改库操作之前都必须备份
mysqldump -uroot -p mydb > /root/mydb_backup_$(date +%Y%m%d).sql
# 统计各表里含 http:// 的记录数(改前摸底)
mysql -uroot -p mydb -e "
SELECT 'wp_posts' t, COUNT(*) c FROM wp_posts WHERE post_content LIKE '%http://%'
UNION ALL
SELECT 'wp_options', COUNT(*) FROM wp_options WHERE option_value LIKE '%http://%'
UNION ALL
SELECT 'wp_postmeta', COUNT(*) FROM wp_postmeta WHERE meta_value LIKE '%http://%';"摸清数量后再执行替换。这里有个重要的技术提醒:不要用 SQL 的 REPLACE 直接全量替换 http:// 为 https://,因为 WordPress 的 option_value 中存在 PHP 序列化字符串,其中长度是写死在字符串里的(例如 s:23:"http://example.com/a.jpg")。盲替换会改变字符串长度但不更新长度标记,导致序列化数据损坏,站点白屏。正确做法是使用 WP-CLI 或者能识别序列化结构的工具:
# 推荐:WP-CLI 的 search-replace 会正确处理序列化数据
wp search-replace 'http://www.example.com' 'https://www.example.com' --all-tables --precise --report-changed-only
# 先干跑一遍确认影响范围,再真正执行
wp search-replace 'http://www.example.com' 'https://www.example.com' --all-tables --dry-runTypecho 的正文存在 typecho_contents.text,相对简单,但同样建议先 dry-run 统计、先备份。另外别忘了附件地址 typecho_metas 和字段表。
第二步:在 Nginx 层做强制修正
把代码里的地址全部改对是治本,但在实践中总有遗漏——第三方插件、用户评论、外部接口回调、缓存里残留的旧 HTML。这时候需要一个兜底机制,让浏览器即使收到 http 引用也能顺利加载。CSP 的 upgrade-insecure-requests 指令就是标准答案:它告诉浏览器「把这个页面上的所有 http 请求自动升级为 https」。
server {
listen 443 ssl http2;
server_name www.example.com;
add_header Content-Security-Policy "upgrade-insecure-requests" always;
# ... 其余配置
}这一行的效果是:浏览器遇到 http://example.com/a.jpg 时,自动改成 https://example.com/a.jpg 再发请求。前提是你的资源服务器本身支持 HTTPS,否则会从「被阻断」变成「404」——问题只是换了个形式,仍然要修。
需要注意的是,upgrade-insecure-requests 是 CSP 家族的一员,如果你的站点已经配置了其他 CSP 策略,不要写两条 add_header,要把指令合并到同一条里,否则后一条会覆盖前一条:
add_header Content-Security-Policy "upgrade-insecure-requests; default-src 'self'; img-src 'self' data: https:;" always;第三步:HSTS 与永久重定向,把 HTTP 彻底关掉
兜底之外,还要从入口掐掉 HTTP。两层配置配合使用:
第一层,HTTP 全站 301 跳转到 HTTPS。这解决「用户直接输入 http 域名访问」的情况:
server {
listen 80;
server_name example.com www.example.com;
# 用 301 永久重定向,把权重完整传递给 HTTPS 版本
return 301 https://www.example.com$request_uri;
}第二层,HSTS(HTTP Strict Transport Security)。它让浏览器记住「这个域名未来 N 秒内只能用 HTTPS 访问」,此后即使用户手动输入 http://,浏览器在本地就直接改写为 https,请求根本不发出去,从根上消除了明文请求被劫持的可能:
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;关于 HSTS,有两条必须知道的注意事项。第一,max-age 要从小往上加,不要一上来就写 31536000(一年)。建议先设 300 秒,确认整站 HTTPS 完全正常、所有子域名都支持 HTTPS 之后,逐步加到 86400、604800、最后才到 31536000。第二,includeSubDomains 要谨慎:一旦生效,所有子域名都被强制走 HTTPS,如果某个子域名(比如老旧的测试站、内网面板)没有证书,用户将完全无法访问,而且这个状态会在浏览器里持续到 max-age 结束,你无法远程撤销。只有在确认所有子域都有有效证书时才加这个参数。
如果确实需要撤销 HSTS,可以先把 max-age 设为 0 并保留一段时间让浏览器更新缓存,再移除响应头。也可以通过 hstspreload.org 申请从预加载列表移除,但流程较慢,所以配置时务必保守起步。
几个极易被忽略的隐蔽来源
最常见的修复难点不是没找到问题,而是「以为改完了其实没改完」。下面这几个来源最容易漏。
一是 CDN 与对象存储的地址。图床、CSS/JS 加速通常走独立域名或 CDN 提供的默认域名。如果当初接入时用的是 HTTP 地址,图片就会全部走明文。解决办法是在资源站(CDN、对象存储)本身启用 HTTPS,拿到证书、开启强制 HTTPS,然后批量替换站内的资源域名。很多云厂商的对象存储支持自带域名与免费证书,配置成本不高。
二是浏览器缓存的旧 HTML。你改完源码,但用户浏览器里缓存着旧的 HTML,里面还是 HTTP 地址。这会造成「你测试正常、用户仍然报错」的错觉。排查时务必用强制刷新(Ctrl+Shift+R)或用无痕窗口,同时确认 HTML 本身的 Cache-Control 不要设置过长。
三是页面里用 JS 动态拼接的地址。这类地址在 HTML 源码里根本搜不到,只有运行时才出现。比如某段统计脚本把 document.location.protocol 之外的协议写死成 http,或者从接口返回的 JSON 里读出一个 http 图片地址。这类问题必须打开浏览器开发者工具的 Network 面板,按 Protocol 列排序,把所有 http/1.1 的请求筛出来看。
四是第三方服务的回调与你无法控制的资源。比如旧版统计代码、老旧的分享按钮、广告素材地址。这类资源如果对方不支持 HTTPS,你只能换服务商或自己代理转发。判断标准很简单:如果控制台报的是某个第三方域名被阻断,那不是你的代码问题,是对方不支持 HTTPS。
用开发者工具做一次彻底核验
修复完成后,用浏览器开发者工具做最终验证,比眼睛看地址栏靠谱得多:
1. 打开开发者工具(F12)→ Console 面板
- 搜索 "Mixed Content",应该零结果
- 搜索 "blocked",确认没有资源加载被拦
2. 切到 Network 面板 → 刷新页面(用 Ctrl+Shift+R 强刷)
- 勾选 "Show all" 确保没被过滤
- 按 Protocol 列排序,检查有没有 http/1.1 的请求
- 或直接用筛选框输入 "http://" 看有没有匹配
3. 切到 Security 面板
- 确认显示 "This page is secure (valid HTTPS)"
- 查看 Certificate 是否是有效证书链
4. 用 Lighthouse 或 PageSpeed Insights 跑一次
- 会直接指出 "Avoids requesting the notification permission"
或 "Does not use HTTPS" 之类的问题项
5. 命令行交叉验证(无浏览器环境时)
curl -s https://www.example.com/ | grep -oE 'http://[^"'"'"' ]+' | sort -u另外建议用第三方在线工具交叉验证,比如 whynopadlock.com,粘贴你的 URL,它会自动扫描页面并列出所有不安全的资源引用及其位置,比自己一步步翻控制台更快。
顺带一件事:证书本身的有效期
排查混合内容时,很多人会误判成证书问题。要区分清楚:锁图标变红有两种原因,一是混合内容(页面内容里混了 http),二是证书本身无效(过期、域名不匹配、链不完整)。前者控制台报 Mixed Content,后者会有明确的 NET::ERR_CERT_DATE_INVALID 之类错误。用 curl -vI https://www.example.com 看证书信息,或者用 openssl 检查:
# 查看证书有效期与颁发者
echo | openssl s_client -connect www.example.com:443 -servername www.example.com 2>/dev/null \
| openssl x509 -noout -dates -issuer -subject
# 检查证书链是否完整(末尾应能看到 "Verify return code: 0 (ok)")
echo | openssl s_client -connect www.example.com:443 -servername www.example.com 2>&1 | tail -5如果用的是 Let's Encrypt,证书 90 天到期,务必确认 certbot 的自动续期定时任务在正常工作,并且续期后 nginx -s reload 真的执行了——只续期不重载,Nginx 内存里还是旧证书,这是非常常见的隐形故障。
写在最后
混合内容的本质是一件很朴素的事:HTTPS 页面里混入了 HTTP 资源,浏览器出于安全考虑拒绝配合。排查思路其实只有三步——用工具把 HTTP 引用找出来、在代码和数据层改成 HTTPS、在 Nginx 层加一层兜底与强制。真正花时间的是第三步之后的查漏:CDN、缓存、动态拼接、第三方资源,这四类来源占了大半的遗留问题。
建议站长把它当成上 HTTPS 之后必须完成的一个收尾环节,而不是可选项。地址栏上的那个小锁,是访客对你站点最直观的信任信号;而在搜索引擎侧,全站 HTTPS 与安全标识同样会影响用户的点击意愿和停留行为。花几个小时把它彻底收拾干净,收益是长期且复利的。