WebSocket 握手 403 排查:nginx Upgrade 正常,为什么还要 Nuxt 服务端补 Authorization

结论先说:WebSocket 返回 403 时,不要继续只盯着 nginx 的 Upgrade 头。

这次请求已经穿过 ALB、nginx、Kubernetes Ingress 并命中后端路由,真正缺的是业务鉴权要求的 Authorization。浏览器原生 WebSocket API 又不能像 fetch 一样随意添加请求头,所以最终方案不是继续堆 nginx 配置,而是在中间增加一层 Nuxt 服务端 WebSocket 代理:从浏览器 Cookie 中取出 token,转成 Authorization: Bearer ... 后再连接 Kubernetes 后端。

以下域名、地址、Cookie 名和服务名均已替换为占位值,落地时换成自己的配置即可。两张 DevTools 截图是根据已核实的现场状态重建的脱敏复现图,不是未处理的原始生产截图。

环境

组件 版本 / 形态
浏览器 Chrome 150,原生 WebSocket API
前端服务 Nuxt 4.4.8、Node.js 22.14.0
WebSocket 客户端 浏览器原生 API;Nuxt 服务端使用 ws 8.21.0
接入层 ALB -> ECS nginx -> Nuxt / Kubernetes Ingress
后端 Kubernetes Service + WebSocket 服务
故障路径 wss://agent.example.com/api/saas/agent/ws

整体链路如下。左边是故障路径,右边是修复路径:

WebSocket 403 与 Nuxt 鉴权桥接架构

一、问题表现:页面能开,普通 API 正常,只有 WebSocket 403

页面、静态资源和普通 REST 请求都能正常返回,但对话功能建立 WebSocket 时失败。

Chrome Console 看到的核心报错是:

WebSocket connection to
'wss://agent.example.com/api/saas/agent/ws' failed:
Error during WebSocket handshake: Unexpected response code: 403

脱敏复现截图如下:

Chrome Console 中 WebSocket 握手返回 403

Network 面板里能看到:

  • Request Method 是 GET
  • Connection: Upgrade 存在;
  • Upgrade: websocket 存在;
  • 服务端明确返回 403 Forbidden
  • 请求里没有后端要求的 Authorization

Chrome Network 中 WebSocket 请求缺少 Authorization

这组证据很重要:Upgrade 头已经有了,服务器也给出了业务状态码,因此不能再把问题简单归因于“nginx 没开 WebSocket”。

二、先用状态码判断请求走到哪一层

WebSocket 排障最容易浪费时间的地方,是看到“握手失败”就反复修改代理头。更有效的做法是先解释状态码代表的链路位置。

状态 常见含义 下一步
400 / 426 HTTP/1.1、Upgrade 或 Connection 不满足升级要求 检查 ALB、nginx 和上游协议头
404 请求到达某个 HTTP 服务,但 Host、Path 或后端路由不匹配 对照 Ingress host/path 和应用 router
401 路由存在,鉴权组件要求登录,但请求没有有效凭证 检查 Bearer token / session Cookie
403 请求到达鉴权或业务层,但当前凭证不被接受 对比浏览器与服务端代理的请求头
502 nginx 找不到可用上游,或者连接上游失败 检查 upstream、Service endpoint 和 Pod
101 WebSocket 握手成功 继续验证消息收发和连接持续时间

不同框架对 401/403 的使用不完全一致,不能只凭状态码下最终结论;但它足以帮助我们停止在错误的层级继续试配置。

用 curl 只测握手

下面的命令不会完成业务会话,只用来检查握手状态。请在能访问目标域名的终端执行:

curl -i -N --http1.1 \
  -H 'Connection: Upgrade' \
  -H 'Upgrade: websocket' \
  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
  -H 'Sec-WebSocket-Version: 13' \
  'https://agent.example.com/api/saas/agent/ws'

如果返回 401/403,至少能确认 TLS、ALB、nginx 和后端路由不是完全断开的。若返回 404,先不要谈 token,应该先核对 Host 和 Path。

三、逐层核对:请求其实已经进入 Kubernetes

1. nginx access log 要记录 upstream

仅记录最终状态不够,建议把实际命中的上游也写入 access log:

log_format upstream_json escape=json '{'
  '"time":"$time_iso8601",'
  '"request":"$request",'
  '"status":$status,'
  '"request_time":$request_time,'
  '"upstream_host":"$upstream_addr",'
  '"upstream_status":"$upstream_status",'
  '"upstream_time":"$upstream_response_time"'
'}';

故障请求的脱敏日志类似:

{
  "request": "GET /api/saas/agent/ws HTTP/1.1",
  "status": 403,
  "upstream_host": "192.0.2.20:80",
  "upstream_status": "403",
  "upstream_time": "0.005"
}

这里已经能看到请求由 nginx 转给了 Ingress 地址,并且 403 是上游在 5ms 内返回的,不是 nginx 自己拼出来的页面。

2. Ingress 必须同时核对 Host 和 Path

在有权限访问集群的机器上执行:

kubectl -n app get ingress agent-api -o yaml
kubectl -n app get service agent-api -o wide
kubectl -n app get endpoints agent-api -o wide
kubectl -n app get pods -l app=agent-api -o wide

需要核对四件事:

  1. Ingress 中存在 agent.example.com
  2. /api/saas 指向预期 Service;
  3. Service 有 endpoint;
  4. Pod Ready 且没有重启。

如果这些都成立,而且后端日志同步出现同一条握手请求,就可以确定“没有转进 Kubernetes”这个假设不成立。

四、真正的根因:浏览器 WebSocket 无法自定义 Authorization

前端代码原本非常直接:

const ws = new WebSocket(
  'wss://agent.example.com/api/saas/agent/ws',
)

浏览器的构造函数只提供 url 和可选的 protocols

new WebSocket(url)
new WebSocket(url, protocols)

它没有类似下面这样的标准参数:

// 浏览器原生 WebSocket 不支持这种写法
new WebSocket(url, {
  headers: {
    Authorization: `Bearer ${token}`,
  },
})

这和 Node.js 中的 ws 库不同。Node.js 运行在服务端,创建连接时可以指定请求头;浏览器受 Web 平台安全模型约束,不能随意给握手添加任意 HTTP header。

MDN 的 WebSocket 构造函数说明也只有 URL 和 subprotocols:

https://developer.mozilla.org/docs/Web/API/WebSocket/WebSocket

当后端鉴权中间件只接受 Authorization: Bearer ... 时,浏览器直连就会卡住:

  • nginx 能转发 Upgrade;
  • Ingress 能匹配 Host 和 Path;
  • Service 和 Pod 都正常;
  • 但浏览器无法按后端契约补 Authorization;
  • 最终后端返回 403。

调试页为什么曾经可以直连 Kubernetes

最初联调时,前端还没有从 SaaS Agent 后端中拆出。后端直接提供了一个静态 WebSocket 握手调试页:

https://agent.example.com/api/saas/agent_ws_debug.html

这里的 agent_ws_debug.html 不是正式 Nuxt 前端,而是后端随服务提供的联调页面。它默认把当前页面的协议和 Host 组装成同域 WebSocket 地址 /api/saas/agent/ws;页面中还有 Cookie 和 x-user-agent 输入框,点击 Connect 时会把它们转成 debug_cookiex_user_agent 查询参数,然后调用浏览器原生 WebSocket 发起握手:

function buildDebugUrl() {
  const url = new URL('wss://agent.example.com/api/saas/agent/ws')
  url.searchParams.set('x_user_agent', '<debug-user-agent>')
  url.searchParams.set('debug_cookie', '<temporary-session-cookie>')
  return url.toString()
}

const ws = new WebSocket(buildDebugUrl())

后端有一条专门的调试鉴权通道,会解析 debug_cookie。因此这个 HTML 页面可以绕过“浏览器无法自定义 Authorization 请求头”的限制,直接经 nginx 和 Ingress 连接 Kubernetes 后端。它本身既是握手发起器,也是观察 opencloseerror 和服务端推送事件的联调界面。

但这只是联调方案,不是正式鉴权模型。Cookie 或 token 放在查询参数中,可能进入浏览器 Console、nginx/Ingress access log、链路追踪和历史记录。调试页内置的 Session 被注销、更换或者不再被当前鉴权服务接受时,握手同样会返回 403;这不代表 WebSocket 没有进入 Kubernetes。

后来前后端分离,独立前端不再依赖后端调试页,也不应继续传 debug_cookie。两个阶段的区别如下:

阶段 浏览器怎样建立 WS 后端怎样取得凭证 定位
最初联调 调试页直连 Kubernetes WS debug_cookie 查询参数解析 Cookie 仅限隔离调试
前后端分离后 浏览器连接同源 Nuxt WS Nuxt 从 Cookie 取 token,再添加 Authorization 连接 Kubernetes 正式方案

这也解释了一个看似矛盾的现象:调试页曾经能直连 Kubernetes,并不能证明正式浏览器客户端也能直连。前者使用了后端专门提供的调试契约,后者必须满足正式的 Authorization 鉴权契约。

五、修复:让 Nuxt 服务端完成鉴权桥接

修复思路是把连接拆成两段:

浏览器 -> 同源 Nuxt WebSocket 路由
Nuxt -> 带 Authorization 的 Kubernetes WebSocket 路由

浏览器只需要正常携带同源 Cookie;Nuxt 服务端读取 Cookie 中的登录态,取出 access token,再使用 Node.js ws 库建立后端连接。

WebSocket 403 到 101 的鉴权桥接过程

下面是简化后的通用写法。实际项目建议优先使用 HttpOnly Session Cookie,避免把长期 token 暴露给前端 JavaScript。

interface AuthSnapshot {
  session_token?: {
    access_token?: string
    token_type?: string
  }
}

function getCookieValue(cookie: string, name: string) {
  return cookie
    .split(';')
    .map((item) => item.trim())
    .find((item) => item.startsWith(`${name}=`))
    ?.split('=')
    .slice(1)
    .join('=') ?? ''
}

function getAuthorizationFromCookie(cookie: string) {
  const raw = getCookieValue(cookie, 'frontend_auth')
  if (!raw) return ''

  try {
    const snapshot = JSON.parse(decodeURIComponent(raw)) as AuthSnapshot
    const token = snapshot.session_token?.access_token
    const type = snapshot.session_token?.token_type || 'Bearer'
    return token ? `${type} ${token}` : ''
  } catch {
    return ''
  }
}

不要打印完整 Cookie 和 token。需要排障时只记录“Cookie 是否存在、是否成功解析、token 长度或哈希前几位”,避免日志成为凭证泄漏源。

2. Nuxt 服务端连接后端 WebSocket

安装服务端 WebSocket 客户端:

pnpm add ws

创建后端连接时补 Authorization:

import WebSocket from 'ws'

export function createBackendSocket(options: {
  browserCookie: string
  backendUrl: string
}) {
  const authorization = getAuthorizationFromCookie(options.browserCookie)

  return new WebSocket(options.backendUrl, {
    headers: {
      ...(authorization ? { authorization } : {}),
      cookie: options.browserCookie,
    },
  })
}

这次最关键的代码变化其实只有三步:

const authorization = getAuthorizationFromCookie(browserCookie)

const backend = new WebSocket(backendUrl, {
  headers: {
    ...(authorization ? { authorization } : {}),
  },
})

3. Nuxt WebSocket 路由负责双向转发

Nuxt/Nitro 开启 WebSocket 后,在路由中取得浏览器 Cookie:

export default defineWebSocketHandler({
  open(peer) {
    const cookie = peer.request.headers.get('cookie') || ''
    const backend = createBackendSocket({
      browserCookie: cookie,
      backendUrl: 'ws://api-test.example.com/api/saas/agent/ws',
    })

    backend.on('message', (data) => {
      peer.send(data.toString())
    })

    backend.on('close', (code, reason) => {
      peer.close(code, reason.toString())
    })

    peer.context.backend = backend
  },

  message(peer, message) {
    const backend = peer.context.backend as WebSocket | undefined
    if (backend?.readyState === WebSocket.OPEN) {
      backend.send(message.text())
    }
  },

  close(peer) {
    const backend = peer.context.backend as WebSocket | undefined
    backend?.close(1000, 'client closed')
  },
})

生产实现还应处理后端连接建立前的消息队列、错误码映射、连接超时和心跳,这里只保留与鉴权桥接直接相关的部分。

六、nginx:WS 精确走 Nuxt,普通 SaaS API 可按契约分流

1. http 层只定义一次 Upgrade map

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      '';
}

upstream web_agent {
    server 127.0.0.1:8080;
}

2. WebSocket 精确路径走 Nuxt

location = /api/saas/agent/ws {
    proxy_pass http://web_agent;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_connect_timeout 10s;
    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;
    proxy_buffering off;
}

这里用精确匹配,是为了保证只有这个 WS 路径进入 Nuxt 鉴权桥接。查询参数不会影响 nginx 的精确 URI 匹配。

3. 普通 API 是否直连 Ingress,要先确认鉴权契约

如果 /api/saas/* 的普通 REST 接口已经能从浏览器请求中取得有效 Cookie 或 Authorization,可以单独直连 Ingress:

location = /api/saas {
    proxy_pass http://192.0.2.20;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

location ^~ /api/saas/ {
    proxy_pass http://192.0.2.20;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

两个普通 API 块的 proxy_pass 确实一样,都指向同一个 Ingress;它们的区别不在上游,而在 location 匹配范围

请求 URI 命中规则 上游
/api/saas location = /api/saas Ingress
/api/saas?from=web location = /api/saas Ingress
/api/saas/jobs location ^~ /api/saas/ Ingress
/api/saas/agent/ws location = /api/saas/agent/ws Nuxt WS 代理
/api/saasx 不命中上述 SaaS 规则 由其他 location 处理

location = /api/saas 只补住不带尾随斜线的根 URI;location ^~ /api/saas/ 覆盖带子路径的请求。nginx 匹配 location 时不看查询参数,所以 /api/saas?from=web 仍会命中精确规则。

因此,这里保留两个块的原因只有一个:同时覆盖 /api/saas 根 URI 和 /api/saas/ 下的子路径。 它们的上游和代理参数相同是正常的,不代表两条规则可以直接删掉一条。

如果接口契约明确不存在 /api/saas 这个裸路径,并且 access log 也确认没有这类请求,那么 location = /api/saas 可以删除,只保留 /api/saas/ 子路径规则。不要仅因为两个块看起来一样就删除;是否删除应由真实请求和路由契约决定。

不建议为了少写一个块而改成 location ^~ /api/saas:它还会匹配 /api/saasx 这类本不属于 SaaS API 的 URI。保留“根 URI 精确匹配 + 子路径前缀匹配”,边界更明确。如果不希望重复代理参数,可以把公共的 proxy_set_header 抽到 nginx include 片段,但不要改变这两个 URI 边界。

精确 WS location 会先于 /api/saas/ 前缀规则命中,因此 WS 请求会进入 Nuxt 鉴权桥接,不会被普通 API 规则抢走。

但要注意:如果 REST 请求同样依赖 Nuxt 从 Cookie 转成 Authorization,就不能直接切到 Ingress。 路由分流必须以鉴权契约为边界,不能只按“前端/后端”四个字判断。

七、上线顺序:备份、校验、reload、对账 upstream

1. 先做目录级备份

在 nginx 所在服务器执行:

cp -a /etc/nginx /etc/nginx.bak.$(date +%Y%m%d_%H%M%S)

如果实际配置不在 /etc/nginx,先从 nginx master 进程参数确认配置目录,不要靠猜。

2. 校验通过后才 reload

nginx -t
nginx -s reload
ps -o pid,lstart,etime,cmd -C nginx

nginx -t 只证明配置语法正确,不证明上游可用,更不证明鉴权成功。

3. 用三组证据闭环

修复后至少核对:

  1. Chrome Network 显示 101 Switching Protocols
  2. nginx access log 显示 WS 实际命中 127.0.0.1:8080
  3. 后端连接持续存在并有消息字节传输,不是握手后立即关闭。

脱敏后的成功日志类似:

{
  "request": "GET /api/saas/agent/ws HTTP/1.1",
  "status": 101,
  "request_time": 444.809,
  "upstream_host": "127.0.0.1:8080",
  "upstream_status": "101"
}

这次修复后连续出现多条 101,连接持续几十秒到数分钟且有数据传输,才算真正闭环。

八、这类问题最容易走的四条弯路

1. 看到 WebSocket 失败就继续加 Upgrade 头

如果 Network 已经显示 Upgrade 存在,且上游返回 401/403,继续重复添加相同请求头没有意义。

2. 把 403 当成“没有进入 Kubernetes”

是否进入 K8s 要看 nginx upstream、Ingress access log、Service endpoint 和 Pod 日志,不能靠感觉判断。

3. 让浏览器 WebSocket 直接带 Authorization

浏览器原生 API 没有任意 headers 参数。可选方案只有调整后端鉴权协议,或者增加同源服务端代理。把 token 放 query string 或 subprotocol 也不是无代价替代:它们可能出现在日志、链路追踪或代理记录中,需要单独做安全评估。

4. 一次把所有 API 都切到 Kubernetes

WebSocket 修好了,不代表 REST 也能绕过 Nuxt。先画清楚每类请求的鉴权来源,再决定 location 分流。

快速参考

状态码速查

400/426 -> 先查 HTTP/1.1、Upgrade、Connection
404     -> 先查 Host、Path、Ingress 和应用 router
401/403 -> 先查鉴权头、Cookie 与 token 转换
502     -> 先查 upstream、Service endpoint、Pod
101     -> 握手成功,再查消息收发与连接持续时间

修复核心

浏览器 WebSocket
  -> 同源 Nuxt WS 路由
  -> 从 Cookie 提取 token
  -> Node.js ws 添加 Authorization
  -> Kubernetes Ingress
  -> 101 Switching Protocols

方案演进速查

早期联调: 后端调试页 -> debug_cookie 查询参数 -> Kubernetes
正式拆分: 浏览器 -> Nuxt -> Authorization 请求头 -> Kubernetes

nginx 路由速查

= /api/saas             -> 只匹配根 URI
^~ /api/saas/           -> 匹配子路径
= /api/saas/agent/ws    -> 精确匹配优先,单独走 Nuxt WS 代理

三条铁律

  • 每一层都用日志或状态亲眼核对,不能用“应该已经到了”代替证据。
  • nginx -t 成功只代表语法正确,最终必须用 101 + upstream + 消息传输 验证。
  • nginx 路由边界应服从鉴权边界;需要服务端补凭证的请求不能直接绕过代理。
posted @ 2026-07-13 12:13  Hello_worlds  阅读(9)  评论(0)    收藏  举报