
下午刚帮同事处理完一个线上问题说起来真的经典到可以当教案页面用HTTPS打开头像全裂套餐图只剩个灰框验证码刷不出来。打开控制台清一色Mixed Content红色的报错刷了快一屏。这种情况我在好几家公司、好几个项目里都见过原因出奇一致——网站从HTTP迁到HTTPS的时候页面里还有一堆子资源用着老的HTTP地址。这个问题的专业叫法是混合内容Mixed Content简单说就是一个HTTPS页面里混进了HTTP请求。浏览器出于安全考虑会把这些HTTP请求直接拦掉。今天这篇文章就把HTTPS页面加载HTTP资源的完整解决路径梳理一遍先讲明白为什么会被拦再给出一套从简单到复杂、从根除到兜底的方案最后把我这些年踩过的坑都写出来。如果你正被这个报错折腾或者接下来要处理HTTPS迁移、网页资源加载失败这类问题这篇文章值得存下来照着做。前端、全栈、运维以及刚接触Web安全的开发者都能从中找到自己需要的部分。1. 问题根源浏览器到底在拦什么1.1 一场浏览器强制执行的“安全检查”要弄明白这个问题得先理解浏览器为什么对HTTP资源这么“零容忍”。两个协议的区别本质上就是一条加密信道和一条明文信道的区别。HTTPS页面本身是经过TLS加密传输的这意味着你访问一个页面时传输内容在中间任何一跳路由器、运营商链路、机房节点都不会被轻易看到或篡改。但如果这个安全页面里去加载一个HTTP资源比如一张图片或一段JavaScript脚本问题就来了后面这个HTTP请求是明文的谁都能看、谁都能改。危害最典型的是脚本。如果攻击者在HTTP链路上拦截并篡改了这个JS文件往里面塞一段盗号代码那整个HTTPS页面的加密保护就形同虚设了——用户看到的地址栏是小锁但页面里跑的实际是已被篡改的代码。所以浏览器的态度很简单宁可让页面看起来坏了也不让这个请求发出去。Chrome控制台里最典型的报错长这样Mixed Content: The page at https://example.com/ was loaded over HTTPS, but requested an insecure resource http://cdn.example.com/js/app.js. This request has been blocked; the content must be served over HTTPS.F12打开看的时候你会发现报错下面往往还跟着一句this request has been blocked这就说明浏览器已经替你把这个请求掐死了。1.2 拦截分两档直接封杀和“先升级再说”不是所有HTTP资源都会被一刀切。浏览器把混合内容分成了两大类处理策略完全不同很多人在排查时没注意到这个区别导致定位问题就走了弯路。第一类叫可阻塞内容blockable content也就是对页面安全性影响最大的资源。包括script脚本iframe嵌入页面fetch、XHR等接口请求CSS样式表form表单提交这类资源一旦是HTTP浏览器直接拦截没有任何商量余地。脚本不执行、接口请求不发、样式不生效页面会以最直接的方式给你“颜色”看。第二类叫可升级内容upgradable content主要指图片、音视频这类媒体资源。在新版Chrome里遇到这类HTTP资源时浏览器会尝试自动升级成HTTPS再请求能加载就不报错只是在Network面板里会有一行小字提醒你是upgraded。但很多老版本浏览器、以及某些内嵌WebView环境并不具备这个能力图片照样裂给你看。还有一点要留意用户从HTTPS页面里手工点击一个http://开头的站外链接、跳转出去这种情况不属于混合内容浏览器不会拦因为跳转后整个页面已经是HTTP环境了。真正需要治理的是页面“主动请求”的那些子资源。1.3 为什么上了HTTPS页面里还到处是HTTP很多人会问都部署HTTPS了页面里的资源怎么还能是HTTP这是个好问题答案其实就是四个字历史包袱。大多数网站不是从零开始全HTTPS的而是先做了很久的HTTP后来才迁移过来。迁移的时候首页模板、公共JS、CSS可能是改了但藏在数据库里的老文章、老用户头像、编辑器里的外链图片、第三方统计代码、合作方给的接口地址通通还是当年的HTTP链接。再加上开发时有些人图省事把图片地址写死在内容里比如img srchttp://img.example.com/a.jpg这种硬编码非常难全文搜出来改。所以你会发现线上出现混合内容报错往往是某个不常更新的落地页、某个用户上传的老图片、某个第三方广告脚本触发的。它不是你不小心犯的错而是网站历史演进中留下的债务。理解了这点再看后面的解决方案思路就清晰了既要处理存量也要堵住增量。2. 方案总览能改源头就改源头改不了就上中转2.1 根治法让资源本身支持HTTPS最彻底、最推荐的方案只有一句话让所有子资源都通过HTTPS提供。具体到操作层面分几类场景来看。自有服务器上的资源比如站点自己的JS、CSS、图片、上传附件直接把资源地址从http://改成https://同时确认服务器上确实配置了证书、443端口可访问这一步基本就完了。这里有个小建议如果你用的是Nginx配置一个从80端口301跳转到443这样就算有遗漏的HTTP链接用户在浏览器里先访问HTTP地址Nginx也会自动给跳到HTTPS版本。跳转之后资源请求变成了HEPS浏览器自然不会再拦。配置示例server { listen 80; server_name example.com; return 301 https://$host$request_uri; }第三方资源比如外链的图片、公共的jQuery CDN、字体库先确认这些域名是否支持HTTPS访问。现在主流CDN服务商基本都支持直接把协议改成https就行。如果不支持有两种选择一是换一个同时提供HTTPS的替代资源源二是把资源下载到自己服务器上托管走自己的HTTPS域名。数据库里存量的历史内容比如文章正文、商品详情里的图片链接我的做法是先导出数据做一遍检查再用脚本批量替换。这里的要点是替换时要小心别把外链里指向其他站点的HTTP地址也一起换成HTTPS了否则可能引发新的加载失败。简单用Python做一个定向替换import re with open(old_content.html, r, encodingutf-8) as f: html f.read() # 只替换自己域名的http链接不动第三方外链 new_html re.sub( rhttps?://img\.example\.com/, https://img.example.com/, html ) # 也可以把站内相对路径统一转成https绝对路径 # new_html re.sub(rsrc(/uploads/, srchttps://example.com/uploads/, new_html) with open(new_content.html, w, encodingutf-8) as f: f.write(new_html)实际替换前一定先备份数据替换后再抽几篇文章人工检查图片显示、链接跳转都要看一遍。2.2 协议相对URL省事但有两个坑业界还有一种常用写法叫协议相对URL就是链接地址不带协议头直接以//开头比如//cdn.example.com/app.js。它的逻辑是如果当前页面是HTTPS浏览器自动发起HTTPS请求页面是HTTP就发HTTP请求。看起来确实是一劳永逸的方案。但这东西有两个坑实测会咬人。第一个坑是某些环境不认//开头的地址。比如一些老的邮件客户端、部分APP内嵌H5容器、某些旧版WebView会把//cdn.xxx.com/a.js解析成file://或者直接报错。你在Chrome里调试完全正常一上用户手机就白屏排查起来相当痛苦。第二个坑是如果你在做服务端渲染或者需要给页面拼接绝对URL的场景协议相对URL会导致后端拼出来的地址缺少协议头万一转给第三方系统用对方可能无法正确处理。所以我的建议是新代码直接写全https://协议相对URL只作为过渡方案用不要当成长期选择。2.3 前置约定用Content-Security-Policy做“自动升级”如果存量链接实在太多、短期改不完还有一招能救急CSPContent Security Policy里有个指令叫upgrade-insecure-requests。它做的事情是当浏览器拿到这个策略后会自动把页面上所有HTTP子资源请求升级成HTTPS再发出。怎么开启两种方式一种是后端响应头另一种是HTML的meta标签。add_header Content-Security-Policy upgrade-insecure-requests;meta http-equivContent-Security-Policy contentupgrade-insecure-requests这个方案最大的优势是你几乎不用改代码浏览器自动帮你在请求层做协议升级。我曾在一次紧急修复里用过它把历史文章页里的几百张HTTP图片全部“救活”了因为那些图片本来就有HTTPS能力只是链接写的是HTTP。但要注意如果某个图片、脚本的源站根本不支持HTTPS或者证书配置有问题那么升级后的HTTPS请求一样会失败而且浏览器不会自动回退到HTTP再试一次。换句话说这只能帮你“拾漏”不能帮你“换底”。用了这个头之后最好再配合下面要讲的代理方案把不能升级的资源兜住。3. 网关与代理方案HTTP资源的“体面出路”3.1 Nginx反向代理把HTTP资源“翻译”成HTTPS有一类HTTP资源比较麻烦它不在你手里是第三方提供的而且第三方只提供HTTP没有HTTPS版本。这种时候前端改代码已经没什么好改的需要做的是在后端加一层中转用你自家域名的HTTPS URL去代理对方的HTTP服务。我习惯的配置套路是这样的。假设第三方服务地址是http://backend.example.com我准备用自己站点的https://my.site/proxy/路径去转发。location /proxy/ { rewrite ^/proxy/(.*)$ /$1 break; proxy_pass http://backend.example.com; proxy_set_header Host backend.example.com; proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-For $remote_addr; proxy_connect_timeout 10s; proxy_read_timeout 30s; }配置好之后页面里把资源地址改成https://my.site/proxy/path/to/file浏览器看到的是一次标准的HTTPS请求自然就不再拦截。实际的HTTP请求由Nginx在后端悄悄发出去用户全程无感知。这个方案里最容易踩的坑是proxy_pass的路径规则。location /proxy/配合rewrite的写法实际上是把URL里的/proxy/前缀去掉再拼到上游地址后面。如果你直接把proxy_pass http://backend.example.com;末尾加个斜杠或者写错了rewrite规则出来的URL可能多一层路径、少一层路径接口直接404。我当年在这个问题上卡过整整一个下午最后用curl逐个对比URL才发现根因。另外别忘了透传一些关键头。Host头如果不设置有些后端会按默认域名解析给出错误内容X-Forwarded-Proto如果不设成https后端程序里如果做了HTTP/HTTPS判断可能会漏一些逻辑。还有超时时间要调够因为上游是HTTP链路可能本身就慢默认的60秒读取超时通常够用但如果后端是流式接口该调就调。3.2 用Nginx sub_filter批量改写页面里的协议除了代理单个资源还有一种更“粗暴”但很有效的做法直接用Nginx的sub_filter模块在返回HTML内容时把http://直接替换成https://。它适合那种模板里大量硬编码了HTTP地址、又来不及改代码的过渡场景。配置长这样sub_filter_once off; sub_filter http:// https://; sub_filter_types text/html;sub_filter_once off表示对响应体里所有匹配片段都替换而不是只替换第一次出现的sub_filter_types text/html限定了只处理HTML类型的内容避免误伤CSS、JS里的字符串。这里有个非常重要的提醒sub_filter会直接把响应内容里的字节替换掉所以如果某个资源源站实际不支持HTTPS你把它替换成https://之后浏览器去请求就大概率失败。这个方案只能用在“源站协议能力没问题、只是页面写错协议头”的场景千万不要指望靠它解决所有HTTP资源问题。再一个坑是Nginx的sub_filter默认只处理text/html但有些后端返回的Content-Type可能是text/plain或application/json如果你的页面本身是接口返回的JSON里嵌了HTML片段替换就不会生效。不要问我怎么知道的都是教训。3.3 接口与fetch类请求怎么处理以上说的主要是静态资源但混合内容报错里还有一大类是JS发起的fetch、XMLHttpRequest甚至WebSocket请求。这类请求在混合内容里属于“可阻塞内容”处理起来要分两个维度看。如果请求发到的是同站点的接口那答案很简单把接口网关改成HTTPS并且让Nginx把80端口的HTTP请求全部301到443。这个在上面已经说过配置一次全局生效。如果请求发到的是第三方HTTP接口那就必须走自己的后端中转。因为从浏览器层面不允许一个HTTPS页面直接向HTTP源发送XHR请求你唯一能做的就是让浏览器请求一个自家域名的HTTPS地址然后由后端代码去转发给第三方HTTP服务。这个思路和前面Nginx反向代理一模一样只不过是一个用网关层转发一个用应用层转发。还有一种情况比较特殊你在本地开发调试一个HTTPS页面想请求本地起的HTTP服务比如http://127.0.0.1:8080。这种请求同样会被拦截。开发环境最简单粗暴的办法是用调试工具Mock或者给本地服务也配一个自签名证书走HTTPS。我现在用的方案更快一些直接用Chrome的“在不安全内容中允许”设置或者启动带--allow-insecure-localhost参数的Chrome实例。但一旦上了测试环境、生产环境这条千万不能用必须老老实实走正确的HTTPS链路。3.4 另一类容易混淆的“接口报错”排查这类问题时我还会遇到一些被误判成混合内容的报错。打个比方你在HTTPS页面里调用一个AI大模型API对方网关返回400说某个字段必须传。这个和混合内容其实没关系是接口协议层面的问题。最近一次帮朋友排查他调用的模型API反复报400报错提示类似要把reasoning_content这种思维链字段原样传回API。那是在HTTP调试工具里能正常复现的和HTTPS页面没有任何关系。所以当你在HTTPS页面遇到接口报错时先别急着往混合内容上靠先在Network面板里看这个请求的URL是什么协议、有没有被浏览器拦截。如果URL已经是HTTPS报的是400、500、502这类状态码那要处理的是后端逻辑、参数格式或网关问题而不是资源协议问题。4. 常见问题排查与避坑实录4.1 一套快速定位混合内容的排查流程问题处理多了我总结出一套固定排查路径新接手这类问题时照着走基本不会漏。第一步打开Chrome的Console面板用过滤条件Mixed Content看一眼全部相关报错。这一步能确认是不是浏览器在拦截HTTP资源以及被拦的具体URL是什么。有时候页面看起来一切正常但日志里已经有一堆警告说明有些资源已经被浏览器悄悄升级或拦截了。第二步切到Network面板看请求列表里有没有红色、灰色或者带着http协议的请求。可以手动在地址栏过滤protocol:http把所有协议不匹配的请求筛出来。这一步能让你快速掌握页面上到底有多少个HTTP资源。第三步看被拦资源属于哪一类。脚本、样式、接口请求优先走源头改造或后端中转图片、字体这类媒体资源可以靠CSP升级指令或反代兜底。根据类型选方案效率最高。对于纯服务端渲染的页面我还会用一段简单脚本把HTML里所有http链接扫出来import re import requests url https://example.com/ resp requests.get(url, timeout10) links set(re.findall(r(?:src|href)[\](http://[^\]), resp.text)) for link in sorted(links): print(link)注意这种方式只能扫出服务端返回的HTML里的链接如果页面是SPA、资源都是JS动态渲染出来的源码里根本看不到。对这种页面最好在浏览器Console里跑Array.from(document.querySelectorAll(script[src],link[href],img[src],iframe[src])) .map(el el.src || el.href) .filter(url url.startsWith(http://));渲染完成后执行能抓出所有实际发出的HTTP请求这是最接近真实情况的排查方式。4.2 高频报错场景速查表整理了一张表这几类问题我都被问过按场景对号入座基本能解决。现象核心原因处理方向图片裂掉Console报Mixed Content图片链接是http浏览器拦截改https、CSP升级、Nginx反代脚本不执行页面样式全乱脚本/CSS是http被直接封杀源头改https临时用sub_filter替换字体加载失败显示备用字体字体文件跨域或混合内容字体走https并配置Access-Control-Allow-Origin接口请求被标红Status显示blockedfetch/XHR发向http地址后端网关转成https或Nginx反代页面能打开但某些模块一直加载中iframe嵌入了http页面嵌入的子页面也要上https浏览器升级后突然出现大批Mixed ContentWebView/浏览器策略收紧排查新增的http链接及时改成https本地调试http://127.0.0.1:xxx被拦本地未加密请求不受信任本机调试可不拦或给本地服务配自签名证书接口调用报400/502等状态码业务参数或网关问题并非混合内容检查API文档、链路日志别往协议上想4.3 几个值得反复咀嚼的亲身踩坑最后说几个我自己的真实教训希望能帮你省点时间。第一个改协议的时候漏了数据库里的历史数据。第一次做整站HTTPS改造时模板、公共库里该改的都改了线上跑了一阵子有用户反馈2018年发的文章里图片全裂了。查下去才发现是早年编辑器拼接的图片地址写死在了数据库内容里模板层面根本看不见。从那以后我每次做站点迁移都会把数据库导一份出来脚本跑一遍http://搜索把所有可能漏掉的内容都拽出来。第二个Nginx反代配好后一直404最后发现是proxy_pass末尾斜杠的问题。proxy_pass http://backend;和proxy_pass http://backend/;一个带斜杠一个不带路径拼接结果完全不同后面还跟着rewrite规则组合起来很容易出乱子。我的习惯是先不加rewrite用最简配置试通再把路径规则加上去一步步来排查效率高很多。第三个upgrade-insecure-requests上线后有些老系统反而打不开了。后来查文档才发现这个指令在部分旧版浏览器和某些嵌入式WebView里根本不被支持浏览器直接忽略CSP头页面里的HTTP资源还是照常请求、照常被拦。所以这个方案只能作为过渡你最终还是要回到源头改协议这条正路上。最后一个值得说的小经验在排查混合内容问题时很多人习惯直接搜代码里的http://但搜出来的结果排山倒海根本无从下手。更好的做法是先通过F12、通过上面那段Console脚本把所有正在被浏览器拦截的资源URL收集齐再反推这些URL是从哪个文件、哪段代码里出来的。以“实际被拦截”为线索往回找比漫无目的地全文搜索高效得多。做完了这一整套方案你会发现这类问题的核心就一句话页面里凡是浏览器直接发出去的请求都必须跑在HTTPS这条路上。为了实现这个目标你可以改代码、调Nginx、上CSP也可以让后端多干点转发的活但唯独不能做的就是让浏览器在网络层去容忍一个不加密的请求。我自己现在处理这类问题已经养成了一个习惯打开一个页面先瞄一眼地址栏是不是HTTPS再顺手把控制台过滤条件切到Mixed Content看一眼。这个习惯救过我很多次也让我在带团队的时候少踩了不少坑。如果你也正在被这个报错折腾得不轻按上面4个H2章节的顺序从排查到方案一样一样过应该很快能收工。