---
topic: NestJS + http-proxy-middleware 反向代理网关的鉴权和头部正确做法
date: 2026-05-10
context: packages/agent-client/gateway 实现 demo 反代时踩坑
---

# 现象

写 NestJS 网关，路由分两段：
- `/health` 走 NestJS controller
- 其它所有 `/v1/*` 走 `http-proxy-middleware` 反代到上游 Anthropic API

按 NestJS 文档套路，把鉴权写成 `NestMiddleware`，在 `AppModule.configure(consumer)` 里
`consumer.apply(GatewayAuthMiddleware).exclude({ path: "health", method: GET }).forRoutes("*")`。

跑 smoke test：
- ✅ `/health` 200
- ❌ `/v1/messages` 不带 token → 期望 401，实际 400（没拦住，请求穿透到上游 Anthropic 然后被 Cloudflare 边缘 400）
- ❌ 带 token 的请求 → 上游 Cloudflare 也返回 400，HTML 报错"Bad Request"，根本没到 Anthropic API

两个问题症状相似，但根因不同。

# 根因 1：NestJS configure 中间件不会拦截 app.use 的 Express 路径

`consumer.apply(...).forRoutes("*")` 只对**通过 Nest controllers 注册的路由**生效。
`app.use(proxyMiddleware)` 注册的纯 Express middleware 在 Nest 的请求解析图之外——
configure 完全看不到这些路径，所以鉴权被跳过。

# 根因 2：proxyReq 里粗暴 removeHeader(k) for all 把 changeOrigin 设的 Host 也清了

`changeOrigin: true` 的语义是 http-proxy-middleware **在 proxyReq 事件触发前**就把 Host
头改成上游域名（`api.anthropic.com`）。如果你在 proxyReq handler 里循环
`for k in proxyReq.getHeaders() { proxyReq.removeHeader(k) }`，会把 Host、content-length 等
关键头一并清掉，再从 `req.headers`（=入站请求的头，Host 是 `localhost:4290`）拷回去——
上游 Cloudflare 边缘直接 400。

# 修法

## 鉴权改成 Express 级 middleware，注册在 proxy 之前

```ts
const app = await NestFactory.create(AppModule, { bodyParser: false });

// Express middleware — 跳过 /health，拦住其它所有
app.use((req, res, next) => {
  if (req.path === "/health") return next();
  const supplied = req.headers["x-gateway-token"] ?? req.headers["x-api-key"];
  if (supplied !== gatewayToken) {
    res.statusCode = 401;
    return res.end(JSON.stringify({ error: "未授权" }));
  }
  next();
});

// 然后才是 proxy
app.use((req, res, next) => {
  if (req.path === "/health") return next();
  return createProxyMiddleware(opts)(req, res, next);
});
```

NestJS 的 `consumer.apply().forRoutes()` 在这种"纯反代场景"基本用不上——保留给真有
Nest controllers 的子路径用。

## proxyReq handler 只精确动需要改的头

```ts
on: {
  proxyReq: (proxyReq, req) => {
    // changeOrigin 已经把 Host 设成了 api.anthropic.com，不要碰
    proxyReq.removeHeader("authorization");
    proxyReq.removeHeader("x-gateway-token");
    proxyReq.setHeader("x-api-key", upstreamApiKey);
    if (!proxyReq.getHeader("anthropic-version")) {
      proxyReq.setHeader("anthropic-version", "2023-06-01");
    }
    fixRequestBody(proxyReq, req); // 安全调用，bodyParser:false 时是 no-op
  }
}
```

# 验证

修完后 smoke test：
- ✅ /health 200
- ✅ /v1/messages 无 token → 401（被网关拦住）
- ✅ /v1/messages 错 token → 401
- ✅ /v1/messages 正确 token + 错的上游 key → Anthropic 返回结构化 JSON
  `{"type":"error","error":{"type":"authentication_error",...}}`——证明三段链路（client → gateway → Anthropic）都通了，鉴权 / 头部 / body 都对了。

# 适用范围

只要在 NestJS 里挂 `http-proxy-middleware`（或任何裸 Express middleware）做反代/反向代理，
都得遵循：

1. **鉴权写成 `app.use(...)` 的 Express middleware，不要写成 `NestMiddleware`**——除非你只对 Nest controllers 鉴权
2. **proxyReq handler 里别全清 header 再重写**——精确点。`changeOrigin` 已经做了你想做的 Host 改写

# 防御

- 任何反代网关写完都跑 4 个 case：health 通、无 token 401、错 token 401、对 token 通到上游（看上游错误结构而非 Cloudflare HTML）
- 看到上游返回 Cloudflare 的 HTML 400 而不是上游应用的 JSON 错误，**99% 是请求头被搞坏了**，不是 body
