最近做 APISIX 线上服务时遇到一个场景:业务使用 websocket 转发时,在浏览器会出现 WebSocket close with status code 1006 的错误。打开调试工具查看发现在 websocket 握手时服务端返回了 403。

76f75e967736245ec923202987a22482_MD5

4efbf4b7a2093ed4f1d6e522139dc92e_MD5

非常奇怪的是,如果业务不经过 APISIX 直接访问后端 code-server 是没有问题的(中间也得经过一层 Ingress 转发)。

简单的流量模型如下:

                                                                                                                      
                                                                                                                      
                               +--------------+                              +--------------+          +-------------+
  https://domain.com:9443/xxx  |              |  http://domain.com:23480/xxx |              |          |             |
--------------------------------   APISIX     -------------------------------+   Ingress    -----------+ code-server |
                               |              |                              |              |          |             |
                               +--------------+                              +--------------+          +-------------+

经过对比发现,两者的请求头里面 Host 和 Origin 是存在差异的。尝试在 APISIX 中强制修改 Host 头部,问题没有解决。然后利用 proxy-rewrite 强制修改 Origin 头部,请求恢复正常。

    "plugins": {
      "proxy-rewrite": {
        "uri": "/anything",
        "headers": {
          "set": {
	        "Origin": "http://domain.com"
          }
        }
      }
    },

那么问题来了,为什么改完 Origin Header 就行了?code-server 是如何处理 Origin Header 的?为什么 Ingress 可以,APISIX 不行?

Origin Header 和 CORS 机制#

先来简单看下 Origin Header 和 CORS 的基础知识。

Origin 头部是一个请求头部,由浏览器自动添加到所有跨域请求和一些同源请求中(如 POST、PUT 等)。它表示发起请求的文档的源(协议、域名和端口)

示例:

  • 如果你的前端应用运行在 http://www.frontend.com:8080,那么浏览器发出的请求的 Origin 头部就是 http://www.frontend.com:8080
  • 这个 Origin 头部会随着请求一起发送到服务器。

CORS (Cross-Origin Resource Sharing) 是一种基于 HTTP 头的机制,它允许浏览器向一个与当前文档不同源的服务器发起请求,并获取响应。它是浏览器同源策略的一种安全增强,允许服务器明确地告诉浏览器“我允许来自某个特定源的请求”。

总结一下,Origin Header 是浏览器通知服务器端源是什么,CORS Header 是服务器端返回给浏览器,告知允许的 Origin 有哪些,让浏览器允许/拒绝跨域连接。

工作流程#

整个流程可以分为两种主要情况:简单请求非简单请求

1. 简单请求 (Simple Requests)#

满足以下所有条件的请求是简单请求:

  • 方法: 只能是 GETHEADPOST
  • 请求头: 除了浏览器自动设置的头部(如 AcceptAccept-LanguageContent-LanguageContent-TypeOrigin)之外,不能有其他自定义头部。
  • Content-Type 只能是 application/x-www-form-urlencodedmultipart/form-data 或 text/plain

工作流程:

  1. 浏览器发送请求:
    • 浏览器判断这是一个跨域请求(例如,前端在 A.com,请求目标是 B.com)。
    • 浏览器直接发送实际的请求给目标服务器 B.com
    • 请求中会自动包含 Origin 头部(例如 Origin: http://A.com)。
  2. 服务器处理请求并响应:
    • 服务器 B.com 收到请求,检查 Origin 头部 (http://A.com)。
    • 如果服务器允许来自 http://A.com 的跨域请求,它会在响应头部中添加:
      • Access-Control-Allow-Origin: http://A.com (或 * 表示允许所有源,但不推荐用于带凭据的请求)
      • 可选地,还可以添加 Access-Control-Allow-Credentials: true (如果请求带了 cookie 等凭据)
      • 可选地,Access-Control-Expose-Headers (暴露自定义响应头给浏览器)
    • 服务器将响应(包含 CORS 头部)发送回浏览器。
  3. 浏览器接收响应并检查:
    • 浏览器收到响应。
    • 它会检查响应头部中是否存在 Access-Control-Allow-Origin
    • 如果存在,且其值与当前请求的 Origin 头部 (http://A.com) 匹配,或者值为 *,则浏览器允许 JavaScript 访问响应内容。
    • 如果不存在,或不匹配,浏览器会拦截响应,并抛出 CORS 错误,JavaScript 无法获取到响应内容。

2. 非简单请求 (Non-Simple Requests)#

所有不满足简单请求条件的请求都是非简单请求。例如:

  • 使用 PUTDELETE 等 HTTP 方法。
  • 发送 JSON 格式数据 (Content-Type: application/json)。
  • 包含自定义请求头。

工作流程(多了一个“预检请求”阶段):

  1. 浏览器发送“预检请求” (Preflight Request):
    • 浏览器判断这是一个跨域的非简单请求。
    • 在发送实际请求之前,浏览器会先发送一个 OPTIONS 方法的请求到目标服务器 B.com
    • 这个 OPTIONS 请求中会包含:
      • Origin: http://A.com
      • Access-Control-Request-Method: POST (告知服务器实际请求将使用的方法)
      • Access-Control-Request-Headers: Content-Type, Custom-Header (告知服务器实际请求将包含的自定义头部)
  2. 服务器处理预检请求并响应:
    • 服务器 B.com 收到 OPTIONS 请求。
    • 它检查 OriginAccess-Control-Request-MethodAccess-Control-Request-Headers
    • 如果服务器允许这种跨域、这种方法、这种头部的请求,它会在 OPTIONS 响应头部中添加:
      • Access-Control-Allow-Origin: http://A.com
      • Access-Control-Allow-Methods: GET, POST, PUT, DELETE (允许的 HTTP 方法)
      • Access-Control-Allow-Headers: Content-Type, Custom-Header (允许的自定义头部)
      • Access-Control-Max-Age: 86400 (可选,预检结果的缓存时间,单位秒)
      • 可选地,Access-Control-Allow-Credentials: true
    • 服务器将预检响应发送回浏览器。
  3. 浏览器检查预检响应:
    • 浏览器收到预检响应。
    • 如果预检响应中的 CORS 头部符合预期Allow-Origin 匹配,Allow-Methods 包含实际请求的方法,Allow-Headers 包含实际请求的自定义头部),则浏览器认为可以安全地发送实际请求。
    • 如果预检响应不符合预期(例如 Allow-Origin 不匹配,或者缺少必要的 Allow-Methods),浏览器会拦截实际请求的发送,并抛出 CORS 错误。
  4. 浏览器发送实际请求 (如果预检通过):
    • 如果预检通过,浏览器才会发送实际的 POST (或 PUT 等) 请求给服务器 B.com
    • 这个实际请求中同样会包含 Origin: http://A.com
  5. 服务器处理实际请求并响应:
    • 服务器 B.com 收到实际请求。
    • 处理请求,并在响应头部中再次添加:
      • Access-Control-Allow-Origin: http://A.com
      • 可选地,Access-Control-Allow-Credentials: true
    • 发送实际响应。
  6. 浏览器接收实际响应并检查:
    • 浏览器收到实际响应。
    • 再次检查 Access-Control-Allow-Origin 头部。
    • 如果通过,允许 JavaScript 访问响应内容。否则,拦截并抛出 CORS 错误。

Websocket 中的 Origin Header#

RFC 6455 10.2 节 中,描述了 WebSocket Server 端对 Origin Header 的考虑:

Servers that are not intended to process input from any web page but only for certain sites SHOULD verify the |Origin| field is an origin they expect. If the origin indicated is unacceptable to the server, then it SHOULD respond to the WebSocket handshake with a reply containing HTTP 403 Forbidden status code.

The |Origin| header field protects from the attack cases when the untrusted party is typically the author of a JavaScript application that is executing in the context of the trusted client. The client itself can contact the server and, via the mechanism of the |Origin| header field, determine whether to extend those communication privileges to the JavaScript application. The intent is not to prevent non-browsers from establishing connections but rather to ensure that trusted browsers under the control of potentially malicious JavaScript cannot fake a WebSocket handshake.

简单来说,服务器端推荐检查 Origin Header,如果不是可信的来源,WebSocket 会返回一个 403。

code-server 对 Origin 的处理#

直接看代码: https://github.com/coder/code-server/blob/v4.104.3/src/node/http.ts#L338-L419

export function authenticateOrigin(req: express.Request): void {
  // A missing origin probably means the source is non-browser.  Not sure we
  // have a use case for this but let it through.
  const originRaw = getFirstHeader(req, "origin")
  if (!originRaw) {
    return
  }

  let origin: string
  try {
    origin = new URL(originRaw).host.trim().toLowerCase()
  } catch (error) {
    throw new Error(`unable to parse malformed origin "${originRaw}"`)
  }

  const trustedOrigins = req.args["trusted-origins"] || []
  if (trustedOrigins.includes(origin) || trustedOrigins.includes("*")) {
    return
  }

  const host = getHost(req)
  if (typeof host === "undefined") {
    // A missing host likely means the reverse proxy has not been configured to
    // forward the host which means we cannot perform the check.  Emit an error
    // so an admin can fix the issue.
    logger.error("No host headers found")
    logger.error("Are you behind a reverse proxy that does not forward the host?")
    throw new Error("no host headers found")
  }

  if (host !== origin) {
    throw new Error(`host "${host}" does not match origin "${origin}"`)
  }
}

从代码中可以看到,通过配置参数 trusted-origins 可以添加指定域名的白名单,跳过 Origin 认证。

在重新配置 code-server 的启动参数后,即使不修改 Origin Header,也能够正常访问了。

对于不在白名单中的 origin,code-server 会对比 Origin Header 和 host,如果不一致,认为是非法请求。

其中,host 会依次从 Forwarded Header,X-Forwarded-Host 和 Host 中取值。而 origin 会经过 ts URL 方法解析,取出来的 host 是带端口的(关键点)

为什么经过 Ingress 可以,经过 APISIX 不行#

Ingress 和 APISIX 底层都是 Nginx,显然,两者对于 X-Forwarded-Host 头部的设置存在差异。

通过抓包发现,对于非标端口请求,APISIX 设置的 X-Forwarded-Host 不带端口,而 Ingress 设置的是带端口的:

8788f54476bde8436be49a26d9edea28_MD5

d75b17bd7db484153544f34ab689de80_MD5

从源码看,APISIX 设置的是 $host 变量:

        -- trusted ones
        -- ref: ngx_tpl.lua#L831-L840
        --
        -- these values are observed directly by APISIX and cannot be forged,
        -- making them highly credible.
        local proto = api_ctx.var.scheme
        local host = api_ctx.var.host
        local port = api_ctx.var.server_port

        -- override the x-forwarded-* headers to the trusted ones.
        -- make sure that the correct values ​​are obtained
        -- in the subsequent stages using `core.request.header`.
        core.request.set_header(api_ctx, "X-Forwarded-Proto", proto)
        core.request.set_header(api_ctx, "X-Forwarded-Host", host)
        core.request.set_header(api_ctx, "X-Forwarded-Port", port)

Ingress 使用的是 $http_host https://github.com/kubernetes/ingress-nginx/blob/652bfff4b4a766e73a6646c29df25f524f7dd75a/rootfs/etc/nginx/template/nginx.tmpl#L1309

            set $best_http_host      $http_host;
            {{ $proxySetHeader }} X-Forwarded-Host       $best_http_host;

而上面的分析,浏览器写入和 code-server 处理 Origin 是带端口的,所以 code-server 无法匹配 APISIX 的请求。