排查记:本地 devServer 是 http,浏览器却把 302 重定向升级成了 https
TL;DR:本地开发服务器(纯 http)+ 资源经 302 相对路径重定向 + 页面里带着
upgrade-insecure-requests(UIR)CSP ------ 浏览器把重定向目标升级成了https://localhost:端口,而本地服务没有 TLS,资源直接握手失败。真凶不是服务器、不是业务代码、也不是 HSTS,而是 UIR 与浏览器重定向升级行为组合出的坑。
一、问题现象
本地开发用 vue-cli 的 devServer(纯 http,端口 8080)调试一个前端项目。index.html 里引入了一组第三方库资源:
html
<script src="/vendor/chart/latest/chart.min.js"></script>
<link rel="stylesheet" href="/vendor/theme/latest/theme.css" />
后端网关对 /vendor/*/latest/* 这类路径返回 302,重定向到带具体版本号的地址("latest 别名 → 固定版本"是很常见的网关行为)。
浏览器(Edge,Chromium 内核)里观察到的现象:
- 重定向之后的请求变成了
https://localhost:8080/vendor/chart/1.2.3/chart.min.js; - 本地 devServer 没配置 TLS,这个 https 请求直接 SSL 握手失败(SSL_PROTOCOL_ERROR),资源加载不出来;
- 把
localhost换成127.0.0.1访问,现象一模一样; - 另一个同类项目用相同方式调试却完全正常;
- 页面上所有
/vendor/*/latest/*资源全部如此,不是个别文件。
期望:全程保持 http。问题是------谁把请求改成了 https?
二、排查过程
2.1 先怀疑服务端:302 的 Location 是相对路径
直接抓 302 响应原样:
bash
GET http://localhost:8080/vendor/chart/latest/chart.min.js
HTTP/1.1 302
location: ../../chart/1.2.3/chart.min.js
Location 是纯相对路径 。按 RFC 3986,相对重定向会继承原请求的 scheme ------ http 请求解析出来只可能是 http,服务器不可能"凭空写出 https"。服务端排除。
2.2 排查业务代码
全局搜索项目源码里 https:// 字符串拼接、location.protocol 相关逻辑,没有发现任何把本地请求改写为 https 的代码。业务代码排除。
2.3 干净环境重放:无头浏览器 + netlog
浏览器会按 host 缓存 HSTS(Strict-Transport-Security),历史访问可能把域名"钉"在 https 上;浏览器扩展也可能改写请求。为排除这些本地状态,用全新临时 profile 的无头浏览器重放,并开启 netlog 网络日志:
bash
# Edge(Chrome 相同,替换可执行文件名即可)
msedge --headless=new --disable-gpu --disable-extensions \
--no-first-run --no-default-browser-check \
--user-data-dir="<一个全新的临时目录>" \
--log-net-log="netlog.json" \
--virtual-time-budget=8000 \
http://127.0.0.1:8080/
结果:干净环境依然复现。浏览器本地状态(扩展 / 缓存 / HSTS 记录)全部排除。
2.4 案发现场:netlog 事件链
netlog.json 是浏览器网络层的完整事件流。按事件类型还原出下面这条链(以其中一个库资源为例):
ruby
1. URL_REQUEST_START_JOB
http://127.0.0.1:8080/vendor/chart/latest/chart.min.js
← 首发请求是 http,正常发出
2. HTTP_TRANSACTION_READ_RESPONSE_HEADERS
HTTP/1.1 302 ... location: ../../chart/1.2.3/chart.min.js
← 服务端返回相对路径,没问题
3. URL_REQUEST_REDIRECTED
{"location":"https://127.0.0.1:8080/vendor/chart/1.2.3/chart.min.js"}
★ 浏览器跟随重定向时,把目标 URL 改写成了 https!
4. TRANSPORT_SECURITY_STATE_SHOULD_UPGRADE_TO_SSL
{"host":"127.0.0.1", ..., "should_upgrade_to_ssl": false}
← HSTS 明确未参与(硬证据)
5. GET https://127.0.0.1:8080/... → SOCKET_POOL {"net_error":-107}
← -107 = SSL_PROTOCOL_ERROR,本地无 TLS,握手失败
结论很清晰:
- 首发请求正常走 http(说明浏览器"知道"127.0.0.1:8080 是本地地址);
- 跟随 302 重定向这一步,浏览器把相对 Location 解析出的 http URL 升级成了 https;
- HSTS 明确排除;升级另有来源。
顺带一提:排查中注意到后端网关的
Strict-Transport-Security响应头会被 devServer 代理原样透传 到 localhost 响应里。虽然 netlog 证明它本次没有参与(should_upgrade_to_ssl: false),但这是另一类隐患,后文"可选防御"里会给处理办法。
2.5 找到开关:页面里的 UIR
回到页面本身,抓 devServer 实际返回的 HTML,看到 head 里有一行:
html
<meta
http-equiv="Content-Security-Policy"
content="upgrade-insecure-requests"
/>
upgrade-insecure-requests(简称 UIR)是一条 CSP 指令,语义是:"这个页面发出的不安全 http 请求,请自动升级为 https"。它通常是给生产环境准备的(全站 https 下把漏网的 http 资源自动升级),却"继承"到了本地开发环境里。
2.6 对照实验:去掉 UIR,升级消失
把这条 meta 改成仅生产构建输出(改法见第四节),devServer 热重编译后用同样的无头浏览器 + netlog 重放对比:
| 指标 | 修复前 | 修复后 |
|---|---|---|
https://127.0.0.1:8080/... 请求数 |
10(全部握手失败) | 0 |
| 302 之后的版本化请求 | 被升级为 https 并失败 | 全部保持 http,200 正常 |
net_error: -107 实例 |
多个 | 0 |
| 页面资源 / HMR | 资源加载失败、热更新断开 | 全部恢复正常 |
闭环完成。
三、原因分析
触发条件是三件事同时成立:
- 页面启用了 UIR (
upgrade-insecure-requestsCSP); - 资源经过 302 相对路径重定向(网关的 latest → 版本号机制);
- devServer 是纯 http(未配置 TLS)。
浏览器(Chromium 内核,含 Edge)的行为细节:
3.1 为什么首发请求是 http
UIR 对首次请求 会豁免本地地址:127.0.0.1 / localhost 属于 potentially trustworthy(可信来源),不满足"不安全请求"的升级条件。所以首发 http 请求照常发出------这正是 netlog 里第 1 条事件的由来。
3.2 为什么重定向变成了 https
在跟随 302 重定向 这条路径上,浏览器对重定向目标执行 scheme 升级时,并未做同样的本地地址豁免:相对 Location 解析出的 http://127.0.0.1:8080/... 被改写为 https://127.0.0.1:8080/...(netlog 的 URL_REQUEST_REDIRECTED 事件留下了改写后的结果)。随后 https 打到没有 TLS 的 devServer 上,SSL_PROTOCOL_ERROR。
换句话说:同一个地址,浏览器"直接请求"时豁免、"重定向过去"时不豁免------这就是整件事最反直觉、也最难凭直觉猜到的点。
3.3 为什么 127.0.0.1 和 localhost 表现一致
两者都是 loopback(本地回环)地址,在这套"首发豁免、重定向升级"的判定里行为相同。所以换 host 排查法(localhost ↔ 127.0.0.1)在本次失灵,也间接提示问题不在"某个域名的缓存状态",而在通用机制。
3.4 为什么其他项目没事 / HMR 为什么也断
- 其他项目正常 :对照项目的
index.html里没有 UIR meta,同样的 302 重定向不会被升级。差异在页面,不在网络路径。 - HMR 也断了 :webpack-dev-server 在
host: 0.0.0.0时,HMR 客户端(sockjs)会使用本机局域网 IP 连接。局域网 IP 不是 loopback、不享受豁免,于是连首发请求 都被升级成https://<LAN-IP>:8080/sockjs-node/info------ 同样握手失败,热更新静默失效。
再次强调 :服务端没有错(302 就是正常相对跳转)、业务代码没有错、HSTS 也没参与(netlog 里 should_upgrade_to_ssl: false 是硬证据)。问题出在"UIR + 302 + 本地 http 服务"组合下浏览器的重定向升级行为(未豁免本地地址,可视为内核实现差异/缺陷)。
四、解决方案
4.1 主方案:UIR 只保留给生产构建
UIR 本来就是生产环境(全站 https)才需要的,开发环境不需要。用构建环境变量把 meta 变成"仅生产输出":
html
<!-- public/index.html,html-webpack-plugin 支持这种模板语法 -->
<% if (process.env.NODE_ENV === 'production') { %>
<meta
http-equiv="Content-Security-Policy"
content="upgrade-insecure-requests"
/>
<% } %>
vue-cli-service build时NODE_ENV为production,serve为development,条件天然生效;- 只改这一处,devServer 热重编译即生效,无需改代理、无需清理浏览器缓存;
- 生产构建照旧输出 meta,线上行为不变。
验证方法(同样是可复用的闭环手法):
- 抓 devServer 返回的 HTML,确认开发环境里已无 UIR meta;
- 用无头浏览器 + netlog 重放,确认
https://127.0.0.1:端口请求数归零、302 后请求保持 http。
4.2 可选防御:剥掉代理透传的 STS 头
生产网关的 Strict-Transport-Security 头会被 devServer 代理原样透传给浏览器。本次故障它不是元凶,但把生产域名的 STS 带到 localhost 有历史前科(浏览器把本地 host 钉成 https 的案例并不少见),建议顺手处理:
js
// vue.config.js ------ 给代理响应剥掉 strict-transport-security
proxy: {
'/api': {
target: 'https://backend.example.com',
changeOrigin: true,
onProxyRes(proxyRes) {
delete proxyRes.headers['strict-transport-security'];
},
},
// 多条代理时,可遍历配置统一注入同一段 onProxyRes
}
4.3 备选:给开发环境配本地 https
另一种思路是"让升级后的请求正确地成功"------用 mkcert 之类签发本地证书,devServer 开 https: true。但如果团队习惯 http 调试、或不想维护本地证书,治本做法还是 4.1 主方案。
五、排查手法小结(可复用)
- 先看服务端响应原样 :用 curl / 脚本抓
Location与响应头,确认"https 是不是服务端写的"。相对 Location + http 请求是不可能解析出 https 的。 - 快速分流嫌疑名单 :当浏览器"自作主张"把 http 变 https 时,嫌疑机制有一组------HSTS、CSP-UIR、HTTPS-First / Automatic HTTPS、浏览器扩展,逐个排除。
- 无头浏览器 + 干净 profile + netlog 是排障取证利器 :
- 干净 profile 剥离扩展、缓存、HSTS 等本地状态;
- netlog 里重点看三类事件:
URL_REQUEST_REDIRECTED(重定向目标被改写成了什么);TRANSPORT_SECURITY_STATE_SHOULD_UPGRADE_TO_SSL(HSTS 是否参与,true/false 一目了然);net_error(-107 = SSL_PROTOCOL_ERROR,直指"打到了无 TLS 的端口")。
- 对照实验闭环:找到可疑开关(UIR meta)后,用同样手法去掉复测,用数据对比(https 请求 10 → 0)说话,而不是"好像好了"。
- 别忽略代理会透传响应头:生产网关的安全头出现在 localhost 的响应里,不是玄学,是代理默认行为。
六、一句话总结
这类问题的通用模式是:浏览器自动升级机制(HSTS / UIR / HTTPS-First)× 重定向 × 本地无 TLS 的 http 服务。排查时只要确认三件事------页面里有什么"升级指令"、HSTS 状态如何、浏览器实际发出了什么请求(netlog)------真相基本就浮出水面了。