H3 工具集(H3 Utils)实战导读:在可组合 HTTP 框架里接住数据、守住边界
【免费下载链接】h3⚡️ Minimal H(TTP) framework built for high performance and portability项目地址: https://gitcode.com/GitHub_Trending/h31/h3
假设你要写一个上传接口:解析 multipart 请求体、限制体积、防路径穿越、用完要清理资源——全靠手写既繁琐又容易出错。本文以可组合 HTTP 框架 H3 为例,带你把 H3 Utils(工具集)按场景走一遍:哪个功能该找哪个函数、哪条线不能碰、每个工具怎么用才安全。读完你应能把自己的需求映射到具体工具,并理解它背后的安全取舍。
可组合到底组合了什么:可组合 HTTP 框架的最小内核
内核只做三件事。H3实例本身只负责请求生命周期:把Request包成H3Event、按路由分发、串行执行中间件。其余一切——请求体解析、响应构造、Cookie、安全校验、反向代理、JSON-RPC——都是独立工具函数,统一从 src/index.ts 的 "Utils" 段导出,src/utils/ 目录下一个文件对应一个功能域,每个函数都带完整 JSDoc 与示例。文档侧的导航见 docs/2.utils/0.index.md,其中也点明了同一件事:要么用内置工具,要么自己写一个。
对比很直接——不用 H3 要手写,用 H3 是一行:
- 请求体解析:手写
Content-Type分支 + 接一个 multipart 解析库 →readBody(event, { type: "formData" }) - CORS:手拼预检与普通响应共五类头、还要处理预检状态码 →
handleCors(event, options) - Cookie 写入:手工序列化、处理同名去重 →
setCookie(event, name, value, options)
两个直接收益。一是按需加载:用不到的能力不 import 就不进代码路径,应用保持轻量;二是签名统一——内置工具与你自写的工具遵循同一套(event) => ...约定,自己写一个解析工具也能无缝混进路由或中间件,不需要额外适配层。
接收入站数据的几条硬规则:H3 请求体解析与参数单层解码
场景还是那个上传接口。先立三条纪律,再谈怎么用。
规则一:解析是 opt-in 的。readBody默认按 JSON 解析,仅当Content-Type为application/x-www-form-urlencoded时回退到 URL 编码解析;multipart/form-data等其他类型必须显式传options.type: "formData",绝不从请求头自动探测——否则任何客户端都能把服务器拖进昂贵的 multipart 解析(src/utils/body.ts 注释里的 #875 问题)。
规则二:体积限制要声明。assertBodySize(event, limit)不是预缓冲,而是把请求包装成"边读边计数"的代理,累计字节超限立即以413中断(谎报偏小的Content-Length也能在流中途被抓住);诚实地超大Content-Length会在处理前直接413拒绝;同时携带Content-Length与Transfer-Encoding的请求按请求走私处理返回400(RFC 7230)。中间件形态bodyLimit(limit)语义相同。
规则三:路由参数只解一层,切勿二次解码。getRouterParams(event, { decode: true })只做一次解码,且路径分隔符(%2f、%5c及其任意%25嵌套)永远保持编码——解码后的参数绝不能长出路由从未匹配过的/或\。解码一次的结果仍可能含百分号转义(如%252e%252e解一次变成%2e%2e),请把它当最终形态校验,不要再次decodeURIComponent,否则../和 NUL 字节会被还原出来。
一个坑:最常见的错误是"decode: true之后再补一次decodeURIComponent保险"——恰恰是第二次解码制造了穿越。
一个短用法:
app.post("/upload", async (event) => { assertBodySize(event, 10 * 1024 * 1024); // 10MB const data = await readBody(event, { type: "formData" }); return `received ${Object.keys(data).length} fields`; });查询侧同理:getQuery(event)取解析结果,getValidatedQuery(event, schema)解析后立即校验(函数、zod、valibot 都可用,onError可定制错误响应);方法校验用isMethod/assertMethod,后者不匹配时抛带Allow头的405(RFC 9110)。行为基准可对照 test/body.test.ts 与 test/unit/path.test.ts。
把响应安全地送出:状态码净化、流式输出与 HTML 转义
场景:渲染一个动态页面、推一个进度流,或者把用户发来的内容写进 HTML。
状态码净化。sanitizeStatusCode保证状态码落在 100–599 的合法整数区间,sanitizeStatusMessage把描述文本过滤到只剩水平制表符、空格与可见 ASCII(RFC 7230 §3.1.2),防止控制字符破坏响应解析。
流式输出。iterable(iterable)按顺序逐块发送,允许在 yield 之间穿插异步任务:每个 chunk 必须是字符串或 Buffer,生成器的返回值与 yield 值同等对待。注意首个 chunk 会在响应创建前被 await,因此第一块之前设置的event.res.status与 headers 仍生效,之后设置的一切都会被忽略(头已上线)。
HTML 转义。html标签模板自动转义插值里的& < > " ';raw(markup)把一段字符串标记为"已信任、已预转义"并直接输出——切勿把用户输入传给raw,否则重新引入 XSS。另注意转义只保护元素内容与带引号的属性值,未加引号的属性、javascript:之类的 URL 属性、<script>/<style>内容需要另行校验。
重定向。redirect(location, status)默认302,响应体附带 meta refresh 页面兜底忽略响应头的客户端。若location来自用户输入,必须对照白名单校验,否则就是开放重定向;"回到上一页"用redirectBack(event, { fallback })更稳妥:它只取同源referer的 pathname(allowQuery: true才保留查询串),缺失或跨源时回退fallback——而fallback必须是硬编码的可信路径。
生命周期清理。onDispose(event, cb)在事件彻底结束(响应体流完、客户端断开或响应体出错)时按注册顺序执行回调,晚于全局onResponse钩子。它表示"h3 处理完了这个事件",不是"客户端已收到响应";要在仍在产出响应时响应客户端断开(比如中止上游 fetch),用event.req.signal。
空负载用noContent(status)(默认 204);writeEarlyHints可写出103 Early Hints,在不原生支持的运行时回退为 CDN 可用的Link头。
信任边界:哪些请求头能信,以及 H3 路径穿越防护
⚠️ 把文档里散落的安全警示收拢成一句话:凡客户端能自行写入的头,默认不可信,要用就 opt-in。场景是你需要回答"客户端是谁"和"这个路径是否合法"。
Host 与 x-forwarded-*。getRequestHost/getRequestURL/getRequestProtocol默认都不信任x-forwarded-*头(各自有独立开关,仅在确认运行在会覆写这些头的可信反向代理/CDN 之后才开启)。由Host头推导出的 origin 不能用于 CSRF/origin 校验、缓存键,或生成发给其他用户的绝对链接——除非上游对 Host 做了白名单固定。toRequest基于host头把相对路径合成完整 URL 时,协议恒为http(忽略x-forwarded-proto),host 仅作 authority 且不能横向扩进路径——想控制 origin 就传绝对 URL;含/ \ ? # @或空白的 host 判为非法并回退localhost。
请求 IP。getRequestIP默认取event.req.ip:连接对端,或服务器被配置信任上游代理后解析出的客户端地址。xForwardedFor: true改为取x-forwarded-for链中第一个条目——代理惯例是往链尾追加,所以那个位置正是客户端自己写的值,开启它等于允许任何调用者自报 IP,IP 白名单、限流与审计全部失效。更优做法是让服务器(如 srvx 的trustProxy)从右往左解析可信链,此选项保持关闭。
H3 路径穿越防护。resolveDotSegments(path, opts)解析./..段,永远不逃逸到根/之上:
- 结果恒为单一前导
/的绝对路径,杜绝//host协议相对形态; - 任意
%25嵌套深度解码百分号编码的点段(%2e、%252e……),并把\规范化为/,因此编码式、反斜杠式穿越与字面../一样被捕获; %2f/%5c(编码的分隔符)默认保持编码。当下游(如 nginx)会自行把%2f解码回/时,安全/范围检查应开启decodeSlashes;下游合并//时开启mergeSlashes;- 尾部
./..按目录处理并保留结尾斜杠(RFC 3986 §5.2.4),内部空段保留。
isCanonicalPath(path, opts)判断路径是否已处于规范形态(即resolveDotSegments会原样返回)。导出它是给热路径(每次请求的 scope 检查、规则匹配)跳过规范化调用用的——文档说得直白:scope 检查里漏掉一次规范化是绕过漏洞,不是性能问题。路由参数若将用作文件系统或上游路径,用这里的能力解析,而不是继续解码(对应上一节"只解一层"的红线)。
会话、认证与跨域:加密会话与 origin 校验
场景:需要记住登录态,还要允许受控的跨域调用。
Basic Auth。basicAuth(opts)是中间件形态,requireBasicAuth(event, opts)对当前请求就地生效:失败抛401并带WWW-Authenticate挑战,成功后把{ username, password, realm }写入event.context.basicAuth。凭据比较用恒定时间函数,且所有 401 路径带计时抖动——响应耗时无法区分"用户不存在"和"密码错误"。
会话。useSession(event, config)创建会话管理器;getSession只读(匿名读取不会下发Set-Cookie,第一次写入才持久化);updateSession接受对象或差量更新函数;clearSession清空;sealSession/unsealSession负责加密签名与解密验签(src/utils/session.ts,底层是 src/utils/internal/iron-crypto.ts 的 iron 加密原语)。几个要点:
password就是安全边界本身:至少 32 字符,且必须来自加密随机源——可猜测的口令即便够长也不安全;maxAge是绝对寿命(从创建起算),idleTimeout是滑动窗口(重密封lastSeenAt前移,且重密封被节流到每半窗口最多一次);- 会话数据加密签名后存入块状 Cookie——超大值自动分块,无需关心浏览器单 Cookie 大小上限;Cookie 属性默认
secure; httpOnly; sameSite=lax。
CORS。handleCors(event, options)一站式处理:预检请求附加预检头并返回204,返回值非false即表示请求已被处理、无需后续动作。appendCorsHeaders/appendCorsPreflightHeaders/isCorsOriginAllowed/isPreflightRequest是更细粒度的底层工具。一个坑:origin 列表中的正则条目是无锚定匹配的,/example\.com/会同时命中https://example.com.evil.test——务必^…$锚定并转义字面点。开启credentials后,浏览器把"*"当作字面方法/头名,应显式列举。
请求指纹。getRequestFingerprint(event, opts)把 ip(默认)、method、url、user-agent 的固定组合哈希成唯一指纹(默认 SHA-256),适合做限流、防滥用的键。每个分量占固定槽位且值经过转义,单个分量的值无法伪造另一个槽位的结构。
把请求转交出去:内部子请求与外部代理的凭据边界
场景:你的服务处在中间,要把请求转走。src/utils/proxy.ts 里三个工具粒度递进:
fetchWithEvent(event, url, init?):携带事件上下文的 fetch。内部 URL(/开头)经event.app.fetch()派发为子请求,永不出进程,继承入站请求的过滤后头与运行时元数据(ip、waitUntil等);外部 URL用原生fetch(url, init)原样发送,事件的头与上下文不被继承。proxyRequest(event, target, opts):把入站请求代理到目标。请求体流式透传、不缓冲,因此此前若读过 body(readBody()、读体中间件)会锁死流导致代理失败——需要"先检查再代理"时从event.req.clone()读取,保持原流完好。proxy(event, target, opts):代理并把响应回传。与proxyRequest的关键差异:默认不转发入站头,只发送调用方通过opts.headers显式传递的头(proxyRequest用的opts.filterHeaders对它无效)。
凭据(Cookie / Authorization)转发策略对比:
| 工具 | Cookie / Authorization 处理 |
|---|---|
fetchWithEvent(内部 target) | 继承入站过滤后头:内置 denylist 已剔除会导致问题的头,属正确行为 |
fetchWithEvent(外部 target) | 从不继承事件头——向任意主机转发凭据不安全 |
proxyRequest | 原样转发入站头;同信任的反向代理正确,对不完全信任的上游用filterHeaders: ["cookie", "authorization"]剥离 |
proxy | 只发显式传入的opts.headers,客户端凭据需你手动传 |
两条共同红线:
- 永远不要把未净化的用户输入当作
url/target——主机白名单、拦截内部路径、强制协议都是调用方的职责;以/开头的内部 target 经event.app.fetch()派发,绕过任何外部安全层(反向代理认证、IP 白名单、mTLS)。 - 上游 3xx 默认透传给客户端而非跟随;需跟随时设
fetchOptions: { redirect: "follow" },但流式请求体无法重放,跟随重定向可能失败。
getProxyRequestHeaders(event)可以取出已剔除"代理时已知会出问题"的头(hop-by-hop 帧头等)的对象,供自行控制转发时使用。
从 JSON-RPC 到 WebSocket:MCP 服务与双向通道
场景:想让 H3 应用直接承担 MCP(Model Context Protocol)等 JSON-RPC 服务端职责,实现见 src/utils/json-rpc.ts。
defineJsonRpcHandler(methods)创建实现 JSON-RPC 2.0 的 H3 事件处理器(只接受 POST,否则405),内置三个值得记住的安全默认值:
- 要求 JSON
Content-Type(否则415)——否则 HTML 表单就构成 CORS "simple request",可带着受害者 Cookie 跨源发出,这是一道 CSRF 防线; - 默认拒绝跨源请求(
403)——默认仅同源放行,防 CSRF 与 DNS rebinding;不带Origin头的请求(CLI、服务间调用)直接放行。若传显式 allowlist,正则同样无锚定,记得锚定; - 批处理上限 50 条——批处理项并发派发,无上限的批会把一次 HTTP 请求放大为任意多次方法调用(扇出放大)。
app.post("/rpc", defineJsonRpcHandler({ methods: { echo: ({ params }, event) => `Received \`${params}\``, sum: ({ params }) => params.a + params.b, }, }));异常也有讲究:只有你自己抛出的HTTPError会暴露message/data,其他内部异常统一遮罩为 "Internal error" 再映射到 JSON-RPC 错误码(HTTP 状态与错误码之间有语义映射,如429 → -32029)。
defineJsonRpcWebSocketHandler(opts)实现 JSON-RPC 2.0 over WebSocket:每条入站文本消息按请求处理并回发响应。注意差异:与 HTTP 版本不同,它不校验请求Origin——WebSocket 升级不受 CORS 约束,任意源页面都能带着访客 Cookie 建立连接(跨站 WebSocket 劫持)。请在upgrade钩子里校验Origin,不匹配时抛出Response中止连接。
适配层与社区生态:对接 Node.js 生态
挂载与事件工具。withBase(base, input)返回新处理器:调用原处理器前先剥离 base 前缀,适合把子应用挂到某路径(app.use("/api/**", withBase("/api", api.handler)))。isEvent/isHTTPEvent/getEventContext/mockEvent是类型判断、取事件上下文、构造模拟事件(测试用)的四个基础件。
中间件钩子。onRequest(hook)/onResponse(hook)/onError(hook)分别挂在请求前、响应后与错误时;onResponse可返回新 Response 替换原响应,onError可返回新 Response 优雅兜底。bodyLimit(limit)即assertBodySize的中间件形态(同为"随读随限",未被读取的 body 不计数)。
WebSocket 工具。defineWebSocket(hooks)定义open/message/close等钩子;defineWebSocketHandler(hooks, http?)定义 WebSocket 事件处理器——非升级(普通 HTTP)请求默认返回426 Upgrade Required,传入http处理器可让同一路由同时服务 WebSocket 升级与普通 HTTP;要自定义升级握手本身用 crossws 的upgrade钩子。
Node 适配器。defineNodeHandler(handler)、defineNodeMiddleware(handler)、fromNodeHandler(handler)、fromWebHandler(handler)全部在 src/adapters.ts 实现,把 Nodereq/res风格处理器带入 H3 事件系统(注意fromNodeHandler的产物只能在 Node 运行时执行)。
社区生态。docs/2.utils/99.community.md 收录了多个与 H3 v2 兼容的库:Apitally(API 监控、分析与请求日志)、H3ravel Framework(把 Laravel 体验移植到 JS 生态)、Intlify srvmid(服务端国际化)、Clear Router(Laravel 风格路由)、unjwt(基于 Web Crypto、零运行时依赖的 JWS/JWE/JWK 工具,含 H3 v2 适配)、Arkstack(运行时无关的 TypeScript 后端框架)。也欢迎社区以 PR 提交新的兼容库。
收尾:最少的代码,最强的安全基线
回看这套工具集,每个决策都有出处:body 解析 opt-in、代理凭据有明确转发策略、路径穿越有编码防御、Host 头有信任边界——"安全的那种用法"在这里都是默认行为。
所有工具直接从包入口导入,例如import { readBody, getQuery, setCookie, handleCors, proxyRequest, defineJsonRpcHandler } from "h3"。src/index.ts 的 "Utils" 段是完整清单,src/utils/ 每个文件对应一个功能域;可运行示例见 examples/(examples/cookies.mjs、examples/cors.mjs、examples/auth.mjs、examples/websocket.mjs、examples/server-sent-events.mjs),行为基准见 test/(test/cookies.test.ts、test/proxy.test.ts、test/security.test.ts、test/session.test.ts、test/json-rpc.test.ts),完整分类导航在 docs/2.utils/。下次要查某个具体行为,先翻这三处,基本不会走偏。
【免费下载链接】h3⚡️ Minimal H(TTP) framework built for high performance and portability项目地址: https://gitcode.com/GitHub_Trending/h31/h3
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考