codecamp

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 更多工具
H3 安全工具
H3 MCP 工具
温馨提示
下载编程狮App,免费阅读超1000+编程语言教程
取消
确定
目录

关闭

MIP.setData({ 'pageTheme' : getCookie('pageTheme') || {'day':true, 'night':false}, 'pageFontSize' : getCookie('pageFontSize') || 20 }); MIP.watch('pageTheme', function(newValue){ setCookie('pageTheme', JSON.stringify(newValue)) }); MIP.watch('pageFontSize', function(newValue){ setCookie('pageFontSize', newValue) }); function setCookie(name, value){ var days = 1; var exp = new Date(); exp.setTime(exp.getTime() + days*24*60*60*1000); document.cookie = name + '=' + value + ';expires=' + exp.toUTCString(); } function getCookie(name){ var reg = new RegExp('(^| )' + name + '=([^;]*)(;|$)'); return document.cookie.match(reg) ? JSON.parse(document.cookie.match(reg)[2]) : null; }