证书申请好了,Nginx 配好了,浏览器地址栏却不是绿色的小锁——或者干脆变成“不安全”。更难受的是页面样式错乱、图片不显示、部分 JS 功能失效,控制台里刷着一堆黄色警告:Mixed Content: The page at 'https://...' was loaded over HTTPS but requested an insecure image 'http://...'。
这就是混合内容(Mixed Content):页面本身通过 HTTPS 加载,但页面里的某些子资源仍走 HTTP。浏览器会拦截其中一部分,放行另一部分,于是就出现了“有些资源能加载、有些不能”的诡异现象。本文从浏览器的拦截规则讲起,给出四类定位方法(控制台、curl 扫描、数据库检索、源码/模板检索),四类修复方案(改代码、改数据、CSP 兜底、代理改写),并附带证书本身不信任时的排查路径。
一、先理解浏览器的拦截规则
不是所有 HTTP 资源都会被拦。浏览器把混合内容分成两类,处理方式完全不同:
| 类型 | 包含的资源 | 默认行为 | 页面表现 |
|---|---|---|---|
| 被动混合内容(Passive / Optionally-blockable) | img、audio、video、object(部分) | 旧版浏览器默认放行,现代浏览器逐步收紧,Chrome 已默认将图片自动升级为 HTTPS(失败则显示为裂图) | 图片不显示或地址栏显示“不安全” |
| 主动混合内容(Active / Blockable) | script、link(CSS)、iframe、XHR/fetch、@import 的 CSS 字体 | 一律拦截,控制台报错 | 页面样式全丢、JS 功能失效 |
| 协议相对/绝对 HTTPS | //example.com/a.js、https://... | 正常 | 无异常 |
理解这张表很重要:被拦的一定是 JS/CSS/iframe/XHR;图片不显示通常不是被拦,而是新版 Chrome 尝试自动升级 HTTPS 后,目标服务器根本不支持 HTTPS 才导致加载失败。
二、如何把问题资源找出来
2.1 浏览器控制台(最直观)
F12 打开开发者工具:
- Console 面板:直接列出被拦的 URL,黄色警告即被动内容,红色错误即主动内容。
- Network 面板:勾选后刷新,看
Scheme列或按http过滤,能列出全部 HTTP 子请求。 - Security 面板(Chrome):Overview 里能看到证书是否有效、是否存在混合内容。
2.2 命令行批量扫描(推荐,能一次性找全)
# 抓首页 HTML,列出所有 http:// 开头的资源地址
curl -s https://www.example.com/ | grep -oE 'src="http://[^"]+"|href="http://[^"]+"' | sort -u
# 更严谨:用 DOM 解析避免正则漏项(需要 python3 + lxml 或 bs4)
python3 - <<'PY'
import urllib.request, ssl, re
from html.parser import HTMLParser
html = urllib.request.urlopen('https://www.example.com/').read().decode('utf-8', 'ignore')
urls = set(re.findall(r'(?:src|href)\s*=\s*["\'](http://[^"\']+)["\']', html))
urls |= set(re.findall(r'url\(\s*["\']?(http://[^)"\']+)', html))
for u in sorted(urls):
print(u)
PY
# 顺手检查 CSS 文件内部是否也引用了 http 资源(极易被忽略) CSS=$(curl -s https://www.example.com/static/style.css) echo "$CSS" | grep -oE 'url\(["\']?http://[^)"\']+' | sort -u # 批量检查多个 CSS/JS for f in /static/style.css /static/app.css /template/default/css/main.css; do echo "--- $f" curl -s "https://www.example.com$f" | grep -oE 'http://[^)"\']+' | sort -u | head done
# 检查 JS 里写死的接口地址(多为请求后端 API 造成 XHR 混合内容) for f in /static/app.js /static/common.js; do curl -s "https://www.example.com$f" | grep -oE '"http://[^"]+"|'http://[^']+'' | sort -u | head done
2.3 数据库里的资源(CMS 站点最常见)
文章内容、幻灯片、站点配置里的图片地址通常写在数据库里,改模板解决不了这一类。
# 先备份,永远是第一步
mysqldump -uroot -p --single-transaction --default-character-set=utf8mb4 dbname \
| gzip > /backup/db_$(date +%F).sql.gz
# 检索哪些表里存在 http:// 开头的资源地址
mysql -uroot -p -N -e "
SELECT TABLE_NAME, COLUMN_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA='dbname'
AND DATA_TYPE IN ('char','varchar','text','mediumtext','longtext');" \
| while read t c; do
n=$(mysql -uroot -p -N -e "SELECT COUNT(*) FROM \`$t\` WHERE \`$c\` LIKE '%http://%'" dbname 2>/dev/null)
[ "${n:-0}" -gt 0 ] && echo "$t.$c : $n 条"
done
三、修复方法一:把 http 改成 https(根治)
3.1 模板与程序里的硬编码
# 在程序目录里找写死的 http:// 资源(排除 vendor/node_modules) grep -rn "http://" /data/www/site --include='*.php' --include='*.html' --include='*.js' \ --include='*.css' --exclude-dir=vendor --exclude-dir=node_modules | head -40
推荐改法:
- 协议相对 URL:
//cdn.example.com/a.js,浏览器会跟随当前页面协议自动选择 http/https,成本最低。 - 直接用根相对路径:
/static/a.js,同站资源首选,连域名都不用写。 - 用站点配置变量:把域名抽到配置项,避免散落各处。
3.2 数据库里的替换
直接 UPDATE ... REPLACE 有风险:序列化数据(WordPress 的 wp_options、部分 CMS 的配置字段会把字符串长度编码进内容里),直接替换字符串会导致长度不匹配,配置被破坏。
# WordPress:用 wp-cli,自动处理序列化,先看 dry-run wp search-replace 'http://www.example.com' 'https://www.example.com' --all-tables --dry-run wp search-replace 'http://www.example.com' 'https://www.example.com' --all-tables # 通用 CMS:优先改后台的“站点域名 / 附件域名”配置项,不要动数据库 # 万不得已用 SQL 时,务必先备份、先小范围验证,且只针对文章内容表 UPDATE 内容表 SET 内容字段 = REPLACE(内容字段, 'http://www.example.com/uploads/', 'https://www.example.com/uploads/') WHERE 内容字段 LIKE '%http://www.example.com/uploads/%';
3.3 编辑上传的新内容(防止复发)
改完存量还不够,富文本编辑器里再传一张图,又会插入 http 地址——如果编辑器的资源域名配置本身写的就是 http。要去 CMS 后台把附件域名 / 资源域名改成 https 或留空(留空则用相对路径),从源头堵住。
四、修复方法二:Content-Security-Policy 兜底
存量内容太多一时改不完时,upgrade-insecure-requests 是最有效的一条指令:告诉浏览器把页面里所有 http:// 子资源自动升级为 https:// 再请求。
server {
listen 443 ssl http2;
server_name www.example.com;
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
root /data/www/site;
# 核心:自动升级页面内的 http 资源
add_header Content-Security-Policy "upgrade-insecure-requests;" always;
# HTTP 全站跳 HTTPS
...
}
server {
listen 80;
server_name www.example.com example.com;
return 301 https://www.example.com$request_uri;
}
使用要点:
always参数很关键,否则 Nginx 只对 200/204/301/302 等少数状态码添加响应头。add_header不会跨层级继承:如果子location里也写了add_header,父级的会被覆盖,需要在子块里重写一遍。- 该指令要求被引用资源确实支持 HTTPS。对方服务器没有 HTTPS,升级后请求失败,图片就变裂图——所以它是兜底,不是根治。
- 更严格时可以加
block-all-mixed-content,但会让所有 HTTP 资源彻底加载失败,生产慎用。 - 想先看效果再生效,用
Content-Security-Policy-Report-Only配合report-uri收集报告。
# 更完整的 CSP 示例(按业务逐步收紧) add_header Content-Security-Policy "upgrade-insecure-requests; default-src 'self' https:; img-src 'self' https: data:; script-src 'self' https: 'unsafe-inline' 'unsafe-eval'; style-src 'self' https: 'unsafe-inline';" always; # 只观察不拦截 add_header Content-Security-Policy-Report-Only "upgrade-insecure-requests; report-uri /csp-report;" always;
五、修复方法三:Nginx 反向代理改写
如果第三方资源确实不支持 HTTPS(老 CDN、外部图片源),可以在 Nginx 上把它代理出来,对外提供 HTTPS 地址:
location /ext-img/ {
proxy_pass http://third-party.example.com/;
proxy_set_header Host third-party.example.com;
proxy_ssl_server_name off;
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
expires 7d;
}
页面里改成 https://www.example.com/ext-img/xxx.jpg,浏览器看到的就全是 HTTPS。缺点是流量要从你的服务器过一遍,需评估带宽与法律风险。
六、另一种“假混合内容”:证书本身没配好
有些场景下,资源已经是 https,页面还是显示“不安全”。这时候问题不在混合内容,而在证书链不完整或证书不匹配。
# 检查证书链是否完整(必须包含中间证书) openssl s_client -connect www.example.com:443 -servername www.example.com < /dev/null 2>/dev/null \ | openssl x509 -noout -subject -issuer -dates # 检查返回的证书数量(应 >= 2,缺中间证书时深度为 0) openssl s_client -connect www.example.com:443 -servername www.example.com -showcerts < /dev/null 2>/dev/null \ | grep -c 'BEGIN CERTIFICATE' # 验证 chain 是否可信 openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt /etc/nginx/ssl/fullchain.pem # curl 是否会报证书错误 curl -vI https://www.example.com/ 2>&1 | grep -iE 'SSL certificate|subject|issuer|expire'
| 现象 | 原因 | 处理 |
|---|---|---|
| Some clients may report missing intermediate | 只配了域名证书,没配中间 CA | ssl_certificate 指向 fullchain.pem(域名证书 + 中间证书) |
| 证书 domain mismatch | 证书域名与访问域名不符 | 补 SAN 或重新申请(含 www 与不带 www) |
| certificate has expired | 证书过期 | 续签并 reload;配自动续期(certbot renew --dry-run 验证) |
| 部分安卓旧机型报不受信任 | 根证书交叉信任问题 | 使用主流 CA,避免冷门根 |
| 页面嵌套 iframe 被拦 | 目标站点设置了 CSP 或 X-Frame-Options | 无法绕过,改用自己的资源 |
# Nginx SSL 基础加固 ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers off; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384; ssl_session_cache shared:SSL:10m; ssl_session_timeout 1d; ssl_stapling on; ssl_stapling_verify on; # 自动续期 certbot renew --dry-run systemctl list-timers | grep certbot
七、混合内容排查与修复清单
- 浏览器 F12 → Console 看报的是 passsive 还是 active 内容(决定紧急程度)。
- Network 面板过滤 http 请求,列出全部问题 URL。
- 用 curl + 正则批量提取首页 HTML 里的 http 资源,避免遗漏。
- 逐一检查 CSS 文件内部的
url(http://...),这是最常被漏掉的地方。 - 检查 JS 里写死的接口地址(
$.get('http://api...')会造成 fetch 混合内容)。 - 检索数据库,定位哪些表哪些字段含 http 资源。
- 先改源头:CMS 后台的站点域名 / 附件域名改为 https 或相对路径。
- 再改存量:模板硬编码改为
//或/;数据用 wp-cli 等安全工具替换。 - 加 CSP
upgrade-insecure-requests兜底,并用 Report-Only 观察一段时间。 - 用
openssl s_client验证证书链完整、域名匹配、未过期。 - 全站 301 到 HTTPS,并检查有无跳转链(最好一步到位)。
- 提交 HTTPS 版 sitemap,清理 CDN 缓存,复测多个页面。
小结
混合内容的处理逻辑其实很简单:把页面里所有 http 资源要么改成 https、要么改成相对路径,改不完的用 CSP 兜底,第三方不支持 HTTPS 的用反向代理中转。真正的难点在于找全——特别是 CSS 里的 url()、JS 里的接口地址、数据库里的历史文章这三类,光看页面源码是找不到的。
避坑清单:
- 图片不显示不等于混合内容被拦,Chrome 会尝试自动升级 HTTPS,失败才裂图——先 curl 验证目标是否支持 HTTPS。
add_header子块会覆盖父块,加了嵌套 location 后要确认 CSP 是否还在。- 务必加
always,否则 4xx/5xx 页面不会带 CSP 头。 - 数据库替换优先用 wp-cli 或 CMS 后台,SQL 直改会破坏序列化数据。
- 不要只改存量,忘改编辑器的上传配置,否则每天都会有新的 http 资源产生。
- 证书必须用
fullchain.pem,缺中间证书是最常见的“少部分浏览器报不受信任”原因。 - 配自动续期并定期检查
certbot renew --dry-run,证书过期比混合内容影响更大。 - 接入 CDN 时要确认回源协议与证书配置(灵活/严格模式),CDN 层配错同样会产生混合内容。
关键词回顾:HTTPS 混合内容、Mixed Content、Content-Security-Policy、upgrade-insecure-requests、证书链完整、fullchain.pem、资源地址替换。
企业QQ咨询




