H3 代理工具
三个代理函数,差别只有一个:转不转发入站请求的头和体。选错的后果不是报错,而是「凭据被泄露给不可信上游」或者「请求体被读空导致代理失败」,都属于上线后才发现的问题。
三者的区别
| 函数 | 转发入站头 | 转发请求体 | 返回 | 典型用途 |
|---|---|---|---|---|
proxy(event, target) |
不转发,只发你显式给的头 | 由 opts 决定 |
响应 | 调第三方 API,不希望带 cookie 过去 |
proxyRequest(event, target) |
转发(含 Cookie、Authorization) |
流式转发 | 响应 | 同信任域的反向代理 |
fetchWithEvent(event, url, init?) |
内部目标继承过滤后的头;外部目标不继承 | 由 init 决定 |
响应 | 在 handler 里发子请求 |
最关键的一行是 proxyRequest 的凭据转发:它会把客户端的 Cookie 和 Authorization 原样送到目标。对同信任域的反向代理这是正确行为;对你不能完全信任的上游,这就是泄露。剥掉的办法是:
import { H3, proxyRequest } from "h3";
const app = new H3().all("/api/**", (event) => {
// filterHeaders 只对 proxyRequest 有效;proxy 会直接忽略这个选项
return proxyRequest(event, "https://upstream.example.com", {
filterHeaders: ["cookie", "authorization"],
});
});
如果你要自己发 fetch 而不是用 proxy*,可以用 getProxyRequestHeaders(event) 拿到「去掉了那些转发会出问题的头」之后的请求头集合——proxyRequest 内部对内部目标也是用同一个函数取头。它返回的 Headers 可以直接喂给原生 fetch 的 headers:
import { getProxyRequestHeaders } from "h3";
const headers = getProxyRequestHeaders(event); // 去掉转发会出问题的头
const res = await fetch("https://upstream.example.com/data", { headers });
内部目标:以 / 开头就留在进程内
proxy 和 proxyRequest 都有一个特殊分支:目标以 / 开头时,不走网络,而是通过 event.app.fetch() 派发成一次子请求:
// 目标不是 URL,而是应用内的另一条路由
app.all("/legacy/**", (event) => proxyRequest(event, "/v2/legacy/**"));
这个设计很省事(不用自己起 HTTP 请求),但它有一个必须知道的安全含义:子请求绕过了任何外部安全层——反向代理上的认证、IP 白名单、mTLS 全部不生效,因为请求根本没出进程。如果你依赖网关做鉴权,内部目标等于没有鉴权。
还有一个容易被利用的细节:前导分隔符连续出现时会被压成一个 /,不会被解析成 authority。所以 //evil.com/x、/\evil.com/x、以及用制表符等 C0 字符隔开的变体,都不会变成「跳转到别人的域名」,而是老实当成本地路径 /evil.com/x。
重定向:默认透传,不跟随
上游返回 3xx 时,默认原样交给客户端,而不是自己去追。要跟随得显式打开:
return proxy(event, "https://upstream.example.com/data", {
fetchOptions: { redirect: "follow" },
});
打开前想清楚一件事:流式请求体是一次性的。跟着重定向再发一次时,body 已经消费完了,这次转发会失败。所以带 body 的 POST 代理,保持默认透传更稳——让客户端自己处理跳转。
内部子请求(/ 开头的目标)永远不跟随重定向,这是实现决定的。
请求体只能消费一次
这条在代理场景下最容易踩:只要之前有人读过 body,代理就会失败。包括 readBody()、readFormData(),以及任何「审计请求体」的中间件。
需要先检查再转发时,从克隆读:
import { H3, proxyRequest } from "h3";
const app = new H3().all("/proxy", async (event) => {
// 从 clone 读,原始流保持可读
const body = await event.req.clone().json();
if (typeof body !== "object") {
return Response.json({ error: "bad body" }, { status: 400 });
}
// 原始请求体仍然完整,可以继续转发
return proxyRequest(event, "https://upstream.example.com");
});
四个继承自 fetch 的限制
用之前先知道这些做不到:
| 限制 | 表现 | 绕过方式 |
|---|---|---|
| 上游响应体总会被解压 | 压缩在两端之间无法原样保留 | 无(fetch 规范决定) |
host 头会被改写成目标 |
上游看不到原 Host | forwardHeaders: ["host"] 仅 Node 有效 |
| unix socket、TLS 选项、连接 agent 不可用 | 需要自定义连接 | 用运行时的逃生口(如 Node 的 undici dispatcher) |
浏览器/Service Worker 下 redirect: "manual" |
返回无法转发的 opaque redirect → 502 | 改成 redirect: "follow" |
最后一条只在 Edge 运行时才会遇到,但踩到时错误信息完全看不出原因——它表现为「代理一律 502」。
一段可直接用的反向代理
import { H3, bodyLimit, proxyRequest, proxy } from "h3";
const app = new H3()
// 1. 先限制体积,防止大 body 拖垮转发
.use("/api/**", bodyLimit(2 * 1024 * 1024))
// 2. 同信任域:保留凭据,走流式转发
.all("/api/**", (event) => proxyRequest(event, "https://internal.example.com"))
// 3. 第三方:不转发凭据,只带必要头
.all("/third/**", (event) =>
proxy(event, "https://api.third-party.com", {
headers: { "x-api-key": process.env.THIRD_PARTY_KEY! },
}),
);
两段的差别正好体现三者的分工:内部用 proxyRequest(要凭据、要流式),外部用 proxy(不带任何入站头,只给显式 token)。
排查表
| 现象 | 最可能原因 | 处理 |
|---|---|---|
| 上游收不到 cookie | 用了 proxy(不转发入站头) |
换 proxyRequest,或显式传头 |
| 上游收到了不该给的凭据 | 用了 proxyRequest 但上游不可信 |
加 filterHeaders,或换 proxy |
| 代理报 body 已被读取 | 前面有人读了 event.req |
改用 event.req.clone() |
| 内部代理绕过了鉴权 | 目标是 / 开头,走了子请求 |
在子请求链路上也挂中间件 |
| 上游 302 客户端没跟着跳 | 默认透传 | 显式 redirect: "follow",注意 body |
| Edge 运行时一律 502 | redirect: "manual" 产出 opaque redirect |
改 follow |
| 大文件代理内存暴涨 | 用了 proxy 且把 body 先读进内存 |
换 proxyRequest 走流式 |
延伸阅读
- 路由规则里的
proxy与目标校验:见 3.3 路由规则代理与自定义 - 请求体只能消费一次的底层原因:见 4.2 H3Event 事件对象
- 大小限制中间件:见 6.9 更多工具

TRAE-AI编程


更多建议: