服务端返回 200、静态资源也都 200,但浏览器打开就是一片空白。
记录一次只在真实 IP 上复现、本地怎么试都正常的排查过程——真正值钱的不是那条 CSP 配置,而是”为什么我用 localhost 验了三轮都没发现”。
正文
1. 现象
给后端接上 Swagger 之后,本地一切正常,部署到服务器上访问:
1 | http://<服务器IP>:5173/api/docs |
页面空白,<body> 里什么都没有。
但奇怪的是,在服务器上和本机 curl 它,返回的都是 200:
1 | $ curl -I http://<服务器IP>:5173/api/docs |
Swagger UI 依赖的三个 JS 和 CSS 也都能拿到:
1 | $ curl -I http://<服务器IP>:5173/api/docs/swagger-ui-bundle.js |
服务端一切正常,但浏览器用不了。 这个组合基本可以锁定:问题在浏览器这一侧,而不是在”接口有没有返回”。
2. 排查:先看浏览器实际发出去什么
打开 DevTools 的 Network 面板刷新,一眼就看到了不对的东西——请求的 URL 全都变成了 https://:
1 | swagger-ui.css net::ERR_SSL_PROTOCOL_ERROR https://<服务器IP>:5173/... |
我明明访问的是 http://,HTML 里的资源也是相对路径,为什么浏览器会去请求 https://?
而且注意:HTML 本身是成功加载的(否则就是”打不开”而不是”白屏”)。所以是”页面拿到了,但页面里的资源全挂了”。
3. 根因:helmet 默认带了 upgrade-insecure-requests
回头去看那个 HTML 的响应头:
1 | content-security-policy: default-src 'self';base-uri 'self';font-src 'self' https: data:; |
最后那个 upgrade-insecure-requests 就是罪魁祸首。它的语义是:
页面里所有”不安全”(http)的子资源请求,浏览器自动帮你升级成 https 再发。
后端用的是 helmet(),这是它默认 CSP 里自带的指令,一行配置都没写也会有。于是链路变成:
- 部署是纯 HTTP(
http://<ip>:5173,服务器上根本没有 TLS); - 浏览器拿到带
upgrade-insecure-requests的 CSP,把 6 个子资源全部改写成https://<ip>:5173/...; - 5173 端口上没有 https 服务 → TLS 握手直接失败 →
ERR_SSL_PROTOCOL_ERROR; - JS/CSS 全没加载 → React 应用起不来 → 白屏。
4. 为什么本地怎么试都复现不出来(本次最大的坑)
这是整件事里最值得记住的一点:
upgrade-insecure-requests只对”非可信来源”生效。
在浏览器的安全模型里,localhost、127.0.0.1、[::1] 属于 potentially trustworthy origin(潜在可信来源)——即使走的是 http,也享受和 https 差不多的待遇,这条指令会被直接忽略。
而线上用的是裸 IP(http://<服务器IP>),不在可信列表里 → 指令生效 → 炸。
回头看我的验证过程,堪称教科书级的”每次都精准绕开”:
| 验证方式 | 结果 | 为什么没抓到 |
|---|---|---|
本机后端 http://localhost:3001/api/docs |
通过 | localhost 是可信来源 |
前端代理 http://localhost:5174/api/docs |
通过 | 同上 |
Docker 起的真 nginx,http://localhost:5183/api/docs |
通过 | 同上 |
| 复刻 nginx 规则的替身代理 | 通过 | 同上 |
线上 http://<服务器IP>:5173/api/docs |
白屏 | 裸 IP,非可信来源 |
四轮验证全绿,问题却一直在。不是验得不够多,是验的环境不对。
5. 修复
思路很简单:既然是”HTTP 部署下这条指令有害、HTTPS 部署下它有益”,那就按部署协议决定带不带:
1 | const isHttpsDeployment = webOrigin.startsWith("https://"); |
几个要点:
helmet的 CSP 默认useDefaults: true,只写directives是增量合并,不会把其它默认指令丢掉;- 把指令的值设为
null才是移除(设为false之类是不行的); - 只在非 HTTPS 部署时移除,将来上了 HTTPS 会自动恢复这个保护,不用回来改代码。
6. 怎么验证才算数
修完不能再用 localhost 验了,得换一个非可信来源。最土但最有效的办法:用局域网 IP 访问同一台机器上的服务。
比如本机是 192.168.x.x,那就访问:
1 | http://192.168.x.x:5183/api/docs |
它和线上一样是”裸 IP”,浏览器对待它的方式和对待 localhost 完全不同,这个 bug 立刻就复现出来了。
为了确认因果、而不是”改了点东西碰巧好了”,我做了个 A/B 对照:同一台机器、两个真 nginx 容器 + 两个后端,只差这一条指令,都用局域网 IP 访问:
| 场景 | Swagger UI 是否渲染 | operation 数量 | 失败请求 |
|---|---|---|---|
修复后(WEB_ORIGIN=http://…) |
✅ 是 | 17 | 0 |
| 模拟旧行为(保留该指令) | ❌ 否 | 0 | 6 条 ERR_SSL_PROTOCOL_ERROR |
下面那行和线上现象完全一致,上面那行才说明修复真的有效。
顺带一个教训:
curl只能证明”服务端返回了什么”,证明不了”浏览器能不能用”。
这两者之间隔着一整层浏览器的安全策略,而那一层恰恰是 curl 看不见的。
7. 可以带走的规律
把这次的坑抽象一下,得到一条可复用的判断规则:
凡是”浏览器行为随来源是否可信而变”的东西,用
localhost验证都不算数。
属于这一类的还有不少,值得记一份清单:
- CSP 的
upgrade-insecure-requests(本次的坑) - 带
Secure属性的 Cookie(localhost 下会被放行,真机上直接不种) navigator.clipboard(剪贴板 API,非安全上下文直接不存在)- Service Worker(只在安全上下文注册)
getUserMedia/ 摄像头麦克风- Web Crypto API
- 地理位置、通知等大部分权限类 API
以后再遇到”本机好好的、线上不对“,第一反应就应该是往这个方向查:**先问一句”这个行为是不是只在可信来源下才成立”**。
而对应的排查 / 验证动作也很明确:
- 用局域网 IP(或域名 + 真实协议)再验一遍,不要只信 localhost;
- 打开 DevTools 看实际发出的请求 URL 和协议,别只看状态码;
- 看响应头里的安全策略(CSP、COOP、Secure cookie…),那是最容易被忽略的一层。
结语
这个 bug 从头到尾都不在”代码写得对不对”的层面,而在”验证环境对不对”的层面。
代码一直是对的,错的是我以为”本地通过”就等价于”线上没问题”。四轮绿灯给的都是虚假的安全感——因为四轮用的是同一个”可信来源”。
所以这次最大的收获不是学会了配 helmet,而是记住了一件事:验证环境和被测代码一样重要,用错环境,验得再多也只是在重复同一个盲区。