排查记:本地 devServer 是 http,浏览器却把 302 重定向升级成了 https

排查记:本地 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 资源加载失败、热更新断开 全部恢复正常

闭环完成。


三、原因分析

触发条件是三件事同时成立:

  1. 页面启用了 UIRupgrade-insecure-requests CSP);
  2. 资源经过 302 相对路径重定向(网关的 latest → 版本号机制);
  3. 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 buildNODE_ENVproductionservedevelopment,条件天然生效;
  • 只改这一处,devServer 热重编译即生效,无需改代理、无需清理浏览器缓存;
  • 生产构建照旧输出 meta,线上行为不变。

验证方法(同样是可复用的闭环手法):

  1. 抓 devServer 返回的 HTML,确认开发环境里已无 UIR meta;
  2. 用无头浏览器 + 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 主方案。


五、排查手法小结(可复用)

  1. 先看服务端响应原样 :用 curl / 脚本抓 Location 与响应头,确认"https 是不是服务端写的"。相对 Location + http 请求是不可能解析出 https 的。
  2. 快速分流嫌疑名单 :当浏览器"自作主张"把 http 变 https 时,嫌疑机制有一组------HSTS、CSP-UIR、HTTPS-First / Automatic HTTPS、浏览器扩展,逐个排除。
  3. 无头浏览器 + 干净 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 的端口")。
  4. 对照实验闭环:找到可疑开关(UIR meta)后,用同样手法去掉复测,用数据对比(https 请求 10 → 0)说话,而不是"好像好了"。
  5. 别忽略代理会透传响应头:生产网关的安全头出现在 localhost 的响应里,不是玄学,是代理默认行为。

六、一句话总结

这类问题的通用模式是:浏览器自动升级机制(HSTS / UIR / HTTPS-First)× 重定向 × 本地无 TLS 的 http 服务。排查时只要确认三件事------页面里有什么"升级指令"、HSTS 状态如何、浏览器实际发出了什么请求(netlog)------真相基本就浮出水面了。

相关推荐
步行cgn1 小时前
Spring p 命名空间注入详解
java·前端·spring
何何____1 小时前
Vue 生命周期详解
前端·javascript
江米小枣tonylua1 小时前
TypeScript 全面の拥抱 !Prisma 8 + Electron 升级实战
前端
闪耀之光M782 小时前
npm命令解析
前端
咸鱼老弟2 小时前
AI Agent 的"自主性悖论"——为什么给它的自由度越大,越要配一套更硬的护栏
前端·人工智能
默_笙2 小时前
💫 闭包是个背包:拆解小米前端面试题里的三道"闭包陷阱"
前端·javascript·面试
天若有情6733 小时前
粒子星空背景单页HTML模板|炫酷动态星空特效网页(纯前端)
前端·html·css动画·网页特效·粒子星空·静态网页模版
工业涂料百问3 小时前
【趋势前沿】系列(三)工业涂料国产替代的三个台阶:航空、海工、电子的突破路径与认证壁垒
前端
前端snow3 小时前
ai agent --- agentic RAG
前端