排查与修复指南)
最近在给一个老项目做 HTTPS 全面改造页面从 http 地址换成 https 地址后控制台几乎被 Mixed Content 报错刷屏。如果你也遇到过 https 页面加载 http 资源被浏览器拦截的情况比如图片突然裂了、脚本没加载、接口请求直接失败这篇文章就是给你准备的。我会从什么是混合内容讲起再到怎么定位、怎么修最后总结一套我能直接抄作业的排查流程希望对正在迁移 HTTPS 或维护老站点的你有帮助。1. 先搞清楚报错从哪来混合内容到底是什么1.1 控制台里的红字与两类报错先明确一下场景。你的站点已经配置了 SSL 证书地址栏出现了小锁图标但页面里有一堆东西不对劲。打开开发者工具Console 面板可能出现这样的红字Mixed Content: The page at https://www.example.com/ was loaded over HTTPS, but requested an insecure resource http://static.example.com/images/logo.png. This request has been blocked; the content must be served over HTTPS.有些浏览器翻译得会更直白比如 Firefox 里是Blocked loading mixed active content http://static.example.com/script.js这种HTTPS 页面里请求了 HTTP 资源的现象就叫混合内容Mixed Content。它的本质不是你的服务器挂了而是浏览器出于安全策略主动拦截掉了一部分请求。拦截力度因资源类型而异后面我会细说。通常你会在这两类场景遇到它。第一类是把老站从 HTTP 整体切换成 HTTPS代码和数据库里大量链接还带着 http 前缀第二类是新项目直接上了 HTTPS但页面里嵌了第三方的 HTTP 资源或者有个只开了 80 端口的内部接口。不管是哪种只要页面协议是 HTTPS浏览器对这类资源就绝不手软。1.2 为什么 HTTPS 页面容不下 HTTP 资源要理解浏览器为什么这么霸道得先明白 HTTPS 到底保护了什么。HTTPS 由 HTTP 和 TLS/SSL 组成核心价值是两件事加密传输和身份认证。加密保证中间人看不到内容认证保证你连上的确实是目标服务器而不是一个冒充的节点。但 HTTPS 只管得住页面本身管不住页面里引用的其他资源。举个最简单的例子我在一个 HTTPS 页面里放了一个img srchttp://cdn.example.com/a.png这个图片请求就是明文发出去的。攻击者在局域网里抓包能看到这个请求的内容更糟的是他可以把这个图片地址劫持返回一张恶意图片。如果是 JavaScript 脚本被劫持攻击者甚至能在脚本里塞入恶意代码用户在自认为安全的页面上输入账号密码、提交订单信息却在不知不觉中泄露给了第三方。这种攻击有个专门的名字叫中间人攻击。浏览器加载初始 HTTPS 页面时建立的信任链会因为一个 HTTP 脚本请求而彻底崩塌。所以浏览器的策略很干脆HTTPS 页面里出现 HTTP 请求先怀疑再说能升级就升级不能升级就拦掉。有人可能会问那我直接在浏览器里关掉这个拦截不就行了技术上确实有临时开关比如 Chrome 地址栏的网站设置里可以把 Insecure content 改成允许但这是只对本地调试有意义的方法。真实用户用的浏览器都在默认拦截你总不能要求每个访客都去改一遍设置。所以正确的思路不是绕过拦截而是让页面里所有请求都跟上 HTTPS 的节奏。1.3 主动混合内容与被动混合内容的差别混合内容不是一刀切处理的浏览器内部把它分成了两类处理方式完全不同。主动混合内容Active Mixed Content是指那些能修改页面行为、能发起新请求的资源主要包括script脚本、link样式表、iframe嵌入页、XMLHttpRequest、fetch、WebSocket 等。浏览器对这类资源的拦截是最严格的从早期版本开始就直接阻断没有商量的余地。原因也好理解脚本一旦被篡改整个页面就等于落在攻击者手里了。被动混合内容Passive Mixed Content主要包括img图片、audio音频、video视频这类展示型资源。早期浏览器会放行顶多把地址栏的锁标成一个不安全的符号后来 Chrome 在 90 版本左右开始对图片类资源做自动升级处理页面里写http://浏览器会先自动尝试用https://去请求如果目标服务器支持 HTTPS用户完全感知不到如果目标服务器只支持 HTTP图片就裂了。把这两类分清楚非常重要因为修复的优先级完全不一样。我在项目里遇到 Mixed Content第一反应永远是先把主动混合内容清干净因为直接关系用户能不能正常操作被动图片可以稍微放一放但最终也必须清零总不能带着一串不安全提示上线。2. 动手前先定位把页面里的 HTTP 资源全部揪出来2.1 开发者工具快速筛查很多人在这一步就乱了在代码里瞎找半天其实浏览器已经把答案喂到你嘴边了。打开 DevTools 切到 Network 面板刷新页面在过滤栏输入protocol:http或者直接敲http://就能筛出所有还在走 HTTP 协议的请求。Chrome 的 Network 列表里可以右键表头勾选 Protocol 列这样请求用的是 h2、http/1.1 还是 http/1.0一眼就能看出来。筛出来以后我习惯按类型分组统计一遍哪些是脚本哪些是样式哪些是图片哪些是接口请求。然后再逐个看域名归属是自建静态资源、第三方 SDK还是后端 API。自建资源改起来最轻松第三方 SDK 要去官方后台重新复制 HTTPS 版本的引入代码接口请求则要判断后端能不能直接上 HTTPS不能就用反代。这里有个容易被忽略的点如果站点用了 Service WorkerNetwork 面板里看到的请求可能不是真实网络请求而是 Service Worker 从缓存返回的伪造响应。你在排查混合内容时最好先开一个无痕窗口或者临时停用 Service Worker避免被假象带偏。2.2 脚本扫描全量资源开发者工具适合单页排查但你要处理的是整个项目、甚至几十个页面模板时手工看页面效率就太低了。我通常会在页面控制台跑一段脚本把所有可能发 HTTP 请求的标签全部扫出来(() { const selectors [ script[src], link[href], img[src], iframe[src], source[src], video source[src], audio[src], embed[src], object[data] ]; const found []; selectors.forEach(sel { document.querySelectorAll(sel).forEach(el { const urlAttr sel.includes(link) ? href : src; const url el[urlAttr]; if (url url.startsWith(http://)) { found.push(${sel}: ${url}); } }); }); console.log(found.join(\n)); })();这段脚本在任何一个页面里执行都会把当前 DOM 里所有http://引用的标签列出来。接口请求没法通过标签扫描看到你需要回到 Network 面板筛选 fetch 和 XHR看它们的 Request URL 是否还是http://开头。如果你用的是前端工程化项目直接在仓库里 grep 也是效率非常高的方式grep -rn http:// src/ --include*.js --include*.vue --include*.tsx注意过滤掉注释里的文档地址和普通字符串还有动态拼接的 URL 也要额外关注比如变量定义了baseUrl字面量里搜不到http://得全局搜一下 baseUrl 是哪里赋值的。2.3 区分资源类型定优先级定位到资源以后不要急着改先按照下面这张表的优先级排序。这张表我借鉴了浏览器对混合内容的处理机制实际排查时按优先级来能少走很多弯路。资源类型浏览器行为优先级script src直接阻断最高iframe src直接阻断最高fetch/XHR/WebSocket直接阻断最高link relstylesheet新版本通常阻断或升级高img/video/audio部分浏览器自动升级升级失败则裂中form action提交时可能被阻止中主动混合内容全部要优先处理因为它们直接影响页面交互。图片类资源可以放到第二轮但也不能一直留着毕竟不安全的标记不会因为加载的是图片就消失。3. 五种实用解决方案详解3.1 协议相对链接改代码最省事的一招最直接的方案当然是让资源本身也支持 HTTPS。具体到代码层面有个非常好用的小技巧是协议相对 URL也就是在引入外部资源时不写死协议头直接写//开头script src//cdn.example.com/lib.js/script img src//static.example.com/logo.png alt页面是 HTTPS 时浏览器会自动把//当成https://去请求页面是 HTTP 时则会自动当成http://。这个方案特别适合资源服务器已经同时支持 HTTP 和 HTTPS、但你不想在代码里写死协议的场景。但它有两个坑必须注意。第一个坑如果你的 HTML 文件会被人在本地用file://协议直接打开协议相对链接会变成file://cdn.example.com/...这种请求直接失败。所以本地静态演示文件里最好写全https://而不是用//。第二个坑如果目标资源服务器实际上不支持 HTTPS协议相对链接就相当于强制把资源升级成 HTTPS反而会让资源挂掉。改之前先确认目标地址用https://能正常打开。3.2 CSP upgrade-insecure-requests全站自动升级如果项目已经线上跑了一段时间HTTP 资源分布在数据库和多个文件里这时候手动逐个改成本太高。我强烈建议你了解 CSP 的upgrade-insecure-requests指令它的作用是告诉浏览器把本页面里所有 HTTP 请求先升级为 HTTPS 再发送。在 HTML 的head里加一行就能生效meta http-equivContent-Security-Policy contentupgrade-insecure-requests也可以在 Nginx 响应头统一加add_header Content-Security-Policy upgrade-insecure-requests always;加上以后浏览器会自动把http://的图片、脚本、接口请求都改写成https://再去请求。如果对应的资源本来就支持 HTTPS那问题瞬间解决甚至不需要你怎么改历史代码。这个方案是我处理存量站点时的首选方案之一。但有一个很关键的前置条件你的所有资源服务器必须真的支持 HTTPS。如果某个图片服务器只开了 80 端口升级后的https://请求会直接失败图片从能用但不安全变成完全裂开。所以加这个头之前先把量大且重要的资源源站验证一遍。如果大部分支持、个别不支持可以针对不支持的资源单独走下一小节的反代方案。还要注意upgrade-insecure-requests对 iframe 的地址同样会尝试升级。不过 iframe 升级后指向的是 HTTPS 地址如果目标服务器没有 HTTPS 服务内容依然打不开这不是 CSP 的锅是目标站的协议能力问题。3.3 Nginx 反向代理第三方资源不支持 HTTPS 时的兜底总有第三方资源真心不支持 HTTPS或者它的证书过期了你也没法控制。这时候最稳妥的办法是让同域的 HTTPS 服务器替你转发也就是反向代理。举个例子页面里要引用http://old-api.example.com/api/getData这个接口只有 HTTP 端口。你可以在当前站点的 Nginx 配置里加一个 locationlocation /proxy-old/ { proxy_pass http://old-api.example.com/; proxy_set_header Host old-api.example.com; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }页面里把对该资源的引用改成script srchttps://你的域名/proxy-old/api.js/script由于最终请求发往的是你的 HTTPS 域名浏览器不再拦截。等于你用自己的 HTTPS 域名给外部 HTTP 服务做了一层中转用户看到的是安全的同域请求实际内容由服务端转发。这里有几个细节值得反复提醒。第一proxy_pass结尾的斜杠非常重要它决定路径会被如何拼接写反了资源会 404。第二如果上游服务会返回 301/302 跳转你需要额外处理proxy_redirect否则浏览器可能又被带回 HTTP 地址。第三如果上游服务有防盗链你需要带着正确的 Host 或 Referer 头转发反过来也要避免自己的 Nginx 被外部滥用成开放代理加上 IP 白名单、Referer 校验或者签名参数会更安全。3.4 后端替换与数据库存量 URL 清洗很多项目里资源地址不是写死在代码里的而是存在数据库里。用户头像、文章配图、附件链接可能存了一堆http://开头的旧地址。这种场景靠前端改模板没用因为数据本身是动态的。常见的做法是在后端输出层做统一处理。比如 PHP 里写一个函数把输出字符串里的http://自动替换成https://Java 项目可以在 JSON 序列化的定制适配器里统一处理 URL 字段。这样改一次全站的数据输出都会变成 HTTPS。如果数据库里的数据非常规整也可以直接写 SQL 批量替换。以 MySQL 为例UPDATE posts SET cover_image REPLACE(cover_image, http://, https://) WHERE cover_image LIKE http://%;执行前一定先备份或者先 SELECT 确认匹配行数防止误伤。我的习惯是分两步走先查后改SELECT COUNT(*) FROM posts WHERE cover_image LIKE http://%;确认规模后再执行 UPDATE。有条件的话最好在测试环境跑一遍再看看业务上有没有需要保留特殊协议的字段。有些字段可能存的是http://localhost/...这种本地调试地址无脑替换会把你坑死。但无脑替换协议头也有一个前提目标服务器同时支持 HTTPS。如果某些图片域名根本没有 HTTPS 能力替换后图片照样挂。所以 SQL 批量替换之前先抽样测试几个资源链接用https://手动打开看看是否能正常返回。3.5 WebSocket 与接口请求的特殊处理页面里如果依赖的不是普通资源而是 WebSocket 连接或 HTTP 接口处理方式要单独说。先说普通接口。页面是 HTTPS接口是http://api.example.com时浏览器会直接阻断请求。最常规的办法是接口也上 HTTPS域名用证书覆盖部署上其实不复杂很多云厂商的负载均衡器可以一键配置 SSL 终结。如果接口暂时无法升级Nginx 反代方案同样适用把/api/反代到内网 HTTP 服务location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; }前端请求改为https://你的域名/api/xxx不仅解决了混合内容还可能顺带解决跨域问题因为同域请求根本不触发 CORS。这个方法在本地开发中也极其常见很多前端脚手架已经内置了 devServer proxy原理就是这个。再说 WebSocket。HTTPS 页面加载不了ws://必须使用wss://。如果你的 WebSocket 服务本身只支持ws://同样可以用 Nginx 转发location /ws/ { proxy_pass http://127.0.0.1:9501/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; }页面里把连接地址改成const ws new WebSocket(wss://你的域名/ws/socket);这里有两个关键点一是 Nginx 需要启用 SSL 模块并配置证书二是必须通过proxy_set_header Upgrade和Connection两个头来支持 WebSocket 的协议升级。证书配置在你的域名上浏览器自然认可混合内容的报错也就不会再出现。4. 实操记录三个真实场景从报错到修复4.1 场景一老站图片全站 HTTP 报错先交代一下背景。一个用 WordPress 搭建的老站点从 HTTP 切到 HTTPS 后发现首页顶部轮播图全部裂了只有文字还在。Console 里报了 Mixed Content指向/uploads/目录下的一堆图片。我第一反应是看 Nginx 配置发现静态资源由同域名承担图片服务器和页面服务器是同一个域名只是协议是 HTTP。对于这种同域存量资源最省事的方案是先用 CSP 升级头兜底。我在 Nginx 的 server 块里加了一行add_header Content-Security-Policy upgrade-insecure-requests always;刷新页面后大部分图片恢复了但有一小部分仍然 404。排查发现这些图片 URL 是用户在编辑器里手动粘贴的老外链指向一个已经停用的旧域名而且那个域名没有 HTTPS 服务。升级后这些 URL 变成https://旧域名/...自然是打不开的。最后的处理方式不是一次性替换所有域名而是先在数据库里用 SELECT 找出所有匹配旧域名的记录确认是历史遗留问题后再用 REPLACE 统一替换成新域名并更新协议。这一步做完图片全部恢复正常。这个案例给我印象很深的地方在于CSP 升级头并不是万能的它只负责把 http 变成 https但如果目标服务器本身已经不可用升级后照样拿不到资源。真正的修复还是要回到数据源头的清洗。4.2 场景二嵌入外部地图与统计脚本被拦截另一个朋友的项目是展示型官网页面嵌了第三方地图 iframe指向http://map.example.com/embed/xxx。iframe 属于主动混合内容浏览器直接拦截地图区域一片空白。第三方平台其实提供了 https 版本代码但某些页面的 HTTPS 嵌入链接不稳定。于是我用了 Nginx 反代把/map/路径代理到地图服务器的 HTTP 地址location /map/ { proxy_pass http://map.example.com/; proxy_set_header Host map.example.com; proxy_set_header X-Real-IP $remote_addr; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; }页面内 iframe 地址改成iframe srchttps://你的域名/map/embed/xxx/iframe这里要特别注意第三方 iframe 内部如果还加载了别的 HTTP 资源比如切片地图图片http://tiles.example.com/xxx.png那即使你把 iframe 入口反代过来了它内部的图片请求仍然是 HTTP浏览器还是可能拦。此时要么继续反代内部资源要么联系第三方确认有没有完整的 HTTPS 方案。统计脚本遇到同样的逻辑。如果统计平台官方 JS 只有 HTTP 版本在 HTTPS 页面里引用会被拦截统计数会明显下降。优先去官方后台拿新版 HTTPS 代码实在拿不到就把 JS 文件下载到自己服务器上用 HTTPS 托管但要注意版本更新避免统计代码失效。4.3 场景三接口请求与 WebSocket 被 Mixed Content 拦截还有一个即时聊天项目很有意思。页面是 HTTPS但 WebSocket 连接地址是从配置中心读取的ws://chat.internal.example.com:8080。浏览器策略更新后连接直接失败控制台报 Mixed Content。因为聊天服务跑在内网机器上短期内没有证书规划最终方案是 Nginx 转发。我配置了wss://chat-public.example.com/映射到内网的ws://192.168.1.20:8080/关键配置如下map $http_upgrade $connection_upgrade { default upgrade; close; } server { listen 443 ssl; server_name chat-public.example.com; # ssl 证书配置略 location / { proxy_pass http://192.168.1.20:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; } }前端把连接地址改成wss://chat-public.example.com/后聊天功能恢复正常。这个方案的好处是证书统一由入口域名掌控内网服务不需要折腾证书坏处是 Nginx 成了 WebSocket 代理中转并发一高就要关注 Nginx 的连接数上限。接口请求场景也值得多说一句。前端项目里常写apiBaseUrl http://api.example.com页面切 HTTPS 后所有请求都会失败。最干净的改法是 API 域名也配上 SSL前端配置改成https://api.example.com。如果 API 域名短期上不了证书就用同域的反代或者网关来解决原理和前面 Nginx 配置一样。5. 常见问题与排查技巧实录5.1 改完还是报错缓存、Service Worker、CDN 的干扰经常有人问我明明把代码里的 HTTP 都改成 HTTPS 了线上为什么还在报 Mixed Content排在最前面的几个原因里缓存绝对算一个。浏览器缓存、CDN 缓存、Service Worker 缓存三层叠加会让旧资源活得很久。我的建议是先开无痕窗口访问页面看看问题是否复现。无痕模式下正常基本就是本地缓存问题强刷CtrlF5或者清掉 Service Worker 再看。无痕模式下也报错就去 CDN 控制台刷新缓存同时确认 CDN 上缓存的是不是旧版本文件。如果项目用了 Service Worker它会在请求发出前拦截并优先从缓存返回。如果缓存策略里没有做协议切换适配缓存里存的还是旧 HTTP 版本响应那页面怎么刷新都不会生效。此时需要更新 Service Worker 的缓存版本号把旧缓存全部清理掉。我踩过这个坑之后凡是涉及协议替换的项目都会顺手把 Service Worker 的CACHE_VERSION变量改一下。5.2 升级后 404目标服务器不支持 HTTPSCSP 的upgrade-insecure-requests帮你把http://自动变https://但如果目标服务器根本不提供 HTTPS 服务浏览器会收到连接错误或 404。这种问题在控制台里的表现特别容易让人困惑Mixed Content 的红色报错消失了但 Network 面板里出现了一堆失败的https://请求。排查这类问题我建议先用 curl 验证目标地址curl -I https://目标域名/某资源如果返回 404、502 或者 SSL 握手失败说明源站不支持 HTTPS。这时需要走同域反代或者联系资源方开通 HTTPS。所以在大规模开启upgrade-insecure-requests之前最好先跑一遍资源清单把不支持 HTTPS 的域名挑出来单独处理避免引发大面积 404。5.3 HTTPS 页面能打开但某些浏览器或 WebView 异常不同浏览器对混合内容的容忍度并不完全一致。Chrome 和 Firefox 对主动混合内容都是零容忍但旧版 Edge、部分国产浏览器、以及各类 App 内置的 WebView对被动混合内容的处理差别很大。常见现象是Chrome 里一切正常某个 App 内置浏览器里图片裂了因为那个 WebView 对图片类混合内容直接拦截而不是自动升级。处理这种问题的思路是优先采用兼容性最好的方案。全量 HTTPS 和协议相对链接是最通用的CSP 升级头在老浏览器上的支持程度参差不齐不能指望它覆盖所有老 WebView。如果你的用户群体里有大量低成本 Android 机或使用内置浏览器保守处理会更稳妥。还有一种反向情况站点没有配置 HSTS用户手动输入http://地址服务器把它 301 跳转到 HTTPS页面打开后部分资源却报混合内容。这种情况往往是因为资源所在域名也在做 301 跳转页面里资源按原协议请求中间发生了多次跳转最终状态不稳定。最好在 Nginx 层做一次干净的 HTTP 到 HTTPS 跳转同时保证页面里所有链接也渲染成 HTTPS。5.4 HSTS、CSP 与安全策略叠加的影响有些站点配置了 HSTS响应头里带Strict-Transport-Security: max-age31536000。HSTS 会让浏览器在有效期内强制使用 HTTPS 访问该域名用户输入 HTTP 地址也会被浏览器本地改写为 HTTPS。这本身是好事但有副作用如果你某个子域名实际上没有 HTTPS 服务HSTS 会让用户完全无法通过 HTTP 访问它包括页面里的资源请求。所以启用 HSTS 之前先确认所有子域名都能正常提供 HTTPS 服务。页面里如果已经写了 CSP 头再叠加upgrade-insecure-requests时要特别注意语法。CSP 头可以用分号分隔多个指令比如Content-Security-Policy: default-src self; upgrade-insecure-requests。如果你在 HTML 里重复写了两个 CSP meta 标签后一个可能覆盖前一个导致指令冲突。我会优先在 Nginx 响应头统一设置 CSP而不是分散在页面里加 meta这样更可控。5.5 排查清单速查表最后把排查顺序整理成速查表方便你照着走步骤操作说明1打开无痕窗口访问页面排除本地缓存、Service Worker 干扰2打开 DevToolsConsole 查看 Mixed Content 报错报错会给出具体被拦截的 URL 和资源类型3Network 面板筛选 protocol:http列出页面里所有 HTTP 请求4用 curl 验证资源目标是否支持 HTTPScurl -I https://目标地址5按资源类型选择方案脚本/接口走反代或升级图片可用 CSP 升级6修改/添加 CSP、Nginx 配置或代码一次只改一类别混合操作7刷新并复查如果仍有报错回到第 2 步8数据库存量链接批量清洗改前备份、SELECT 确认9CDN、Service Worker 缓存刷新确保线上拿到的是新资源这张表是我每次做 HTTPS 改造都会过的流程。遇到复杂项目多走几轮是正常的别指望一次全解决。我还想补充一个临时调试的技巧在本地开发环境想快速验证某个资源是不是混合内容的锅可以临时在 Chrome 的网站设置里把 Insecure content 改成允许但用完一定要改回来这只适合开发不应该出现在生产环境的建议里。处理了这么多次https 页面加载 http 资源的问题我最大的体会是所有修复方案都只是在把问题推到合适的位置真正干净的解法还是让每一个资源都能用 HTTPS 访问。能升级资源就升级资源升级不了就用反代兜底而 CSP 升级头适合用来覆盖那些你还没有发现的存量链接。下次再看到 Mixed Content 报错先别慌它的信息比大多数报错都详细按上面的流程走一遍基本上半小时内就能找到原因并把问题解决掉。