网站开启HTTPS后出现混合内容怎么办?资源地址与证书配置排查

证书申请好了,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只配了域名证书,没配中间 CAssl_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

七、混合内容排查与修复清单

  1. 浏览器 F12 → Console 看报的是 passsive 还是 active 内容(决定紧急程度)。
  2. Network 面板过滤 http 请求,列出全部问题 URL。
  3. 用 curl + 正则批量提取首页 HTML 里的 http 资源,避免遗漏。
  4. 逐一检查 CSS 文件内部的 url(http://...),这是最常被漏掉的地方。
  5. 检查 JS 里写死的接口地址($.get('http://api...') 会造成 fetch 混合内容)。
  6. 检索数据库,定位哪些表哪些字段含 http 资源。
  7. 先改源头:CMS 后台的站点域名 / 附件域名改为 https 或相对路径。
  8. 再改存量:模板硬编码改为 // 或 /;数据用 wp-cli 等安全工具替换。
  9. 加 CSP upgrade-insecure-requests 兜底,并用 Report-Only 观察一段时间。
  10. 用 openssl s_client 验证证书链完整、域名匹配、未过期。
  11. 全站 301 到 HTTPS,并检查有无跳转链(最好一步到位)。
  12. 提交 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、资源地址替换。