这篇文章写给所有在本地开发时被浏览器报错
ERR_SSL_PROTOCOL_ERROR整崩溃过的人。
背景
我使用ngrok给我的前端做了一个内网穿透。但后端一直不接受http请求。后端跑的是 HTTP,前端发的是 HTTPS,两者对不上,浏览器给了一个 ERR_SSL_PROTOCOL_ERROR。修复方案写了三层,每一层都有对应的代码证据。整个排查过程涉及:SSL 协议层、Vite 代理路由层、业务会话上下文层。
第一章:事情是怎么发生的
用户打开前端页面,点击任何一个需要后端数据的功能,浏览器 network 面板直接红:
GET https://localhost:8000/api/...
ERR_SSL_PROTOCOL_ERROR
同一时间,后端日志里出现:
WARNING: Invalid HTTP request received.
这个警告是 uvicorn 抛出来的。uvicorn 收到了一个它根本看不懂的请求——因为客户端发来的是 TLS 握手包,而 uvicorn 根本没有启用 TLS,它启动命令是:
uvicorn http://0.0.0.0:8000
没有 --ssl-keyfile,没有 --ssl-certfile,就是纯 HTTP。
所以整件事的本质很简单:前端用了 HTTPS 去打一个 HTTP 服务器的端口,服务器不认识 TLS 握手,直接丢弃,浏览器报 SSL 错误。
但"为什么前端会用 HTTPS 去请求 localhost",这才是真正需要拆开说的部分。
第二章:前端是怎么一步步走到 HTTPS 的
场景一:开发者用 HTTPS 打开了 Vite 开发服务器
Vite 支持 HTTPS 模式启动。如果开发者本地配置了 --https 或者浏览器历史记录里有 https://localhost:5173,那么所有从这个页面发出去的 fetch 请求,如果 base URL 是绝对路径 https://localhost:8000,就会直接绕过 Vite proxy,用 HTTPS 去打后端。
而 Vite proxy(vite.config.ts:11)配置的是把 /api 转发到 http://localhost:8000,这个 proxy 只在相对路径请求时生效。一旦前端代码里写死了 https://localhost:8000,请求就直接出去了,proxy 根本插不上手。
场景二:通过 ngrok 暴露后在本地调试
ngrok 给你一个 https://xxxx.ngrok.io 的域名,前端页面从这个域名加载。此时 window.location.protocol 是 https:,window.location.hostname 是 xxxx.ngrok.io(不是 localhost)。
如果前端的 API base URL 逻辑是"我在 HTTPS 环境,所以我用 https://localhost:8000 来请求后端",那就出问题了。从 ngrok 的 HTTPS 页面发出 https://localhost:8000 的请求,浏览器不会走 Vite proxy(因为你不是在 localhost 上),请求直接打到本机 8000 端口,而那里跑的是 HTTP,凉了。
第三章:修复是怎么做的?
修复分三个层次
层次一:normalizeApiBase
这个函数处理"当前环境到底该用什么 base URL"的问题。
逻辑是:如果检测到当前是 HTTPS 环境或远程 host,但目标是 localhost,就回退为空字符串(相对路径)。
空字符串意味着请求走的是 /api/... 这种相对路径,这样 Vite proxy 就能接管,把它转发到 http://localhost:8000。
这一步解决了"HTTPS 页面不小心拼出 https://localhost:8000"的问题。
层次二:installLocalhostFetchPatch
这是一个更激进的兜底。它在 window.fetch 上打了一个 monkey patch:拦截所有目标是 https://localhost 的请求,把它们重写成 http://127.0.0.1:xxxx。
为什么要用 127.0.0.1 而不是 localhost?因为某些浏览器对 localhost 有特殊的安全策略处理,用 127.0.0.1 更保险。
这一步是防御性的,即使上面那一层没拦住,这里也能把 HTTPS 的 localhost 请求"降级"到 HTTP。
层次三:Vite Proxy
proxy: {
'/api': 'http://localhost:8000'
}
所有走相对路径 /api/... 的请求,在 Vite dev server 层面就被代理到后端,完全不经过浏览器的 HTTPS/HTTP 协议判断,是最干净的解法。
同时 vite.config.ts:10 里 allowedHosts 包含了 ngrok 域名,确保通过 ngrok 访问时 Vite 不会拒绝请求。
明白了,你想把这一章从"这套方案的局限"扩展成一篇更有普适价值的 SSL 错误指南——用这次排查作为引子,讲清楚开发者最常碰到的那几类 SSL 问题。我来重写这两个部分:
第四章:SSL 报错那么多,到底哪种是哪种
ERR_SSL_PROTOCOL_ERROR 只是浏览器 SSL 错误家族里的一个成员。把它们放在一起看,你会发现每一种错误背后的根因其实差异很大,但开发者往往一看到 SSL 就开始检查证书,其实南辕北辙。
ERR_SSL_PROTOCOL_ERROR:协议对不上
这就是本文的主角。不是证书的问题,是客户端发了 TLS 握手,服务端根本不认识这个握手。最常见的触发条件:后端跑 HTTP,前端用 HTTPS 去打;或者服务端配置的 TLS 版本太低(比如只支持 TLS 1.0),而客户端要求 TLS 1.2 以上。
排查方向:先用 curl -v https://your-host:port 看连接阶段的输出,确认服务端有没有在做 TLS 握手响应。如果 curl 直接报 SSL handshake failure,问题在服务端;如果 curl 能通但浏览器不行,问题在浏览器侧(HSTS、证书信任等)。
ERR_CERT_AUTHORITY_INVALID:CA 不被信任
证书是真实的,但签发这张证书的 CA 不在浏览器的信任链里。本地开发用 openssl 自签名证书时最常见。解法有两个:一是用 mkcert 这类工具生成本地可信证书(它会把自己的 CA 写入系统信任库);二是在 Chrome 地址栏输入 thisisunsafe 临时跳过(仅限开发调试,绝对不能用于生产)。
ERR_CERT_COMMON_NAME_INVALID:域名对不上
证书是有效的,CA 也可信,但证书里写的域名和你实际访问的域名不一致。比如证书颁发给 api.example.com,你用 www.example.com 去访问,就报这个错。通配符证书(*.example.com)可以解决同一域下多子域的问题,但它不覆盖根域本身,也不覆盖二级以上的子域。
用 ngrok 做内网穿透时有时会碰到这个,因为 ngrok 的域名每次可能不同,而你本地配置的证书是固定域名。
ERR_CERT_DATE_INVALID:证书过期
最好排查也最尴尬的一种——证书到期了。Let's Encrypt 的免费证书有效期是 90 天,如果自动续签的 cron job 挂了,就会在某天突然全站 SSL 报错。运维侧应该有证书过期的提前告警(比如到期前 30 天、7 天各发一次通知)。
检查命令:
echo | openssl s_client -connect your-domain:443 2>/dev/null | openssl x509 -noout -dates
输出里的 notAfter 就是过期时间。
NET::ERR_CERT_REVOKED:证书被吊销
证书被 CA 标记为不可信,原因通常是私钥泄露或者证书错误签发。浏览器会通过 OCSP(Online Certificate Status Protocol)或 CRL(Certificate Revocation List)实时查询证书状态。这种错误在开发阶段几乎不会遇到,生产环境一旦出现,需要立即联系 CA 重新签发。
HSTS 导致的强制 HTTPS(没有专属错误码,但很坑)
HSTS(HTTP Strict Transport Security)是服务端通过响应头 Strict-Transport-Security 告诉浏览器:"以后访问我这个域名,只准用 HTTPS。"浏览器会把这个策略缓存下来,即使后来服务端改回 HTTP,浏览器也拒绝发 HTTP 请求。
本地开发最容易踩这个坑:你之前在某个端口跑过 HTTPS 服务并发了 HSTS 头,后来改回 HTTP,结果浏览器死活不肯发 HTTP 请求,报的错看起来像 SSL 问题,但其实是 HSTS 缓存在作怪。
解法:Chrome 里打开 chrome://net-internals/#hsts,在 "Delete domain security policies" 里输入对应的域名或 localhost,删掉缓存。
最后
回头看这次排查,ERR_SSL_PROTOCOL_ERROR 这个报错本身其实挺有误导性的——它让人第一反应是去检查证书、检查 TLS 配置,但真正的问题是连 TLS 都没启用,谈何配置。
SSL 报错的排查有一个基本原则值得记住:先确认 TLS 在哪一层断掉的,再去找断掉的原因。 是服务端根本没有 TLS(本文的情况)、还是握手失败(协议版本不兼容)、还是握手成功但证书校验失败(域名不对、CA 不信任、已过期)——这三个阶段的问题,修法完全不同,不能混为一谈。
最短排查路径:curl -v https://target:port 看握手阶段输出,能比浏览器给你更原始的错误信息,省掉很多猜测。