H3 完全上手指南:极简 HTTP 框架如何内置安全边界
【免费下载链接】h3⚡️ Minimal H(TTP) framework built for high performance and portability项目地址: https://gitcode.com/GitHub_Trending/h31/h3
H3 是一个可组合、运行时无关的极简 HTTP 框架:核心只负责事件、路由与中间件编排,请求体解析、响应构造、Cookie、会话、代理、JSON-RPC 全部以独立工具函数形式按需取用,且每个函数都内置了明确的安全默认值。
它解决什么问题
写 HTTP 服务时,痛点很少是"少一个功能",而是"功能之间谁替我守边界"。请求体多大算超限?超限是预缓冲还是边读边算?multipart 要不要自动嗅探?x-forwarded-for第一个值能不能信?路由参数解码一次还是两次?这些问题在 Express 时代大多要自己查 RFC 自己判断,写错一个就是漏洞。
H3 的思路是把每个边界写进源码并在 JSDoc 里讲清楚理由:readBody绝不从请求头自动探测 multipart,assertBodySize拒绝同时携带Content-Length和Transfer-Encoding的请求,getRouterParams解码后也永远不会还原出/。你不用先当半个安全专家,才能安全地写一个 handler。
30 秒跑起来
import { H3, serve, readBody, assertBodySize } from "h3"; const app = new H3(); // 轻量实例,路由由 rou3 提供 app.get("/", () => "hello world"); // 返回字符串即 200 text/plain app.post("/", async (event) => { assertBodySize(event, 10 * 1024 * 1024); // 声明 10MB 上限,读体超了抛 413 const data = await readBody(event); // 默认按 JSON 解析 return { ok: true, data }; // 返回对象自动序列化为 JSON }); serve(app); // 绑定 app.fetch 到运行时服务器所有工具函数都从包入口导出,清单见 src/index.ts。Node、Bun、Deno、Cloudflare 各有适配入口(如 src/_entries/node.ts 里的serve与toNodeHandler),同一份应用代码可直接换运行时。
场景一:接住请求——先验边界再取数据
请求体超限怎么拦
assertBodySize(event, limit)不是预缓冲,而是把event.req换成 srvx 的limitRequestBody限流代理:字节边读边计数,累计超过limit的瞬间抛413。它还有两道前置防御(src/utils/body.ts):
Content-Length诚实超标 → 处理前直接413,不碰流;- 同时带
Content-Length与Transfer-Encoding→400,这是请求走私向量(RFC 7230)。
注意语义边界:限流绑定"消费",chunked 请求的超限只在 handler 真正读体时触发;从未读取的 body 不计入。想要中间件形式就用bodyLimit(limit)(src/utils/middleware.ts),语义完全相同。
请求体怎么解析、何时校验
readBody(event, options?)默认按 JSON 解析;仅当Content-Type为application/x-www-form-urlencoded时回退 URL 编码。multipart/form-data必须显式传type: "formData",源码注释直接点明原因(src/utils/body.ts):
// 反例:依赖 Content-Type 自动嗅探 multipart // → 不可信请求可以把应用拖进昂贵的 multipart 解析 // 正例:显式 opt-in,解析失败抛 400 const body = await readBody(event, { type: "formData" });解析后立即校验用readValidatedBody(event, validate, { onError? }):validate可以是普通函数,也可以是 Standard-Schema 兼容库(zod、valibot);onError可接收issues自定义statusText与汇总信息。查询参数同理:getQuery(event)取解析对象(嵌套解析用event.url.search拿原始串),getValidatedQuery(event, schema)读完即校验(src/utils/request.ts)。
场景二:发出响应——流式输出与清理
流式响应怎么发
iterable(source)把生成器/异步迭代器变成流式响应,chunk 限字符串或 Buffer。关键行为:首个 chunk 在响应创建前被 await——也就是说第一个 chunk 之前你设置event.res.status和 headers 都还生效,第一个 chunk 之后设置一律被忽略,因为头已经上线路了(src/utils/response.ts)。生成器return的值与yield值同等对待。
return iterable(async function* work() { yield "<ol>"; for (let i = 0; i < 10; i++) { await delay(1000); yield `<li>job #${i} done</li>`; } return "</ol>"; // return 值同样发出 });HTML 输出用标签模板html
,插值自动转义& < > " ';已有转义标记的字符串用raw包起来绕过转义。**raw` 只接受你信任的已转义片段,把用户输入传进去等于重新引入 XSS。**重定向与资源清理
redirect(location, status?)默认302,响应体附带 meta refresh 页面兜底忽略头的老客户端。redirectBack(event, { fallback?, allowQuery? })基于referer返回上一页:默认只取 pathname(剥掉 query 与 hash),跨源或缺失时落回fallback(默认"/")。fallback必须是硬编码路径,传用户输入就是开放重定向。
onDispose(event, cb)注册"事件彻底结束"回调:响应体流完、客户端断开或出错的任意一种都算结束,按注册顺序在全局onResponse之后执行。注意它表示h3 处理完了,不是"客户端已收到";要在产出响应过程中响应客户端断开(比如中止上游 fetch),监听event.req.signal。
app.get("/task", (event) => { const timer = setInterval(tick, 1000); onDispose(event, () => clearInterval(timer)); // 流结束/断开即清理 return iterable(stream); });场景三:守住安全边界——路径、来源与会话
路由参数怎么解码
getRouterParams(event, { decode: true })只解码一层,且编码的路径分隔符(%2f、%5c及其任意%25嵌套)永远保持编码(src/utils/request.ts)。这意味着:
// GET /files/%252e%252e/x getRouterParams(event); // { rest: "%252e%252e/x" } 原始形态 getRouterParams(event, { decode: true }); // { rest: "%2e%2e/x" } —— 到此为止 // 反例:decodeURIComponent(params.rest) → "../x",路径穿越 // 正例:把返回值当最终值校验;要落文件系统先过 resolveDotSegments解码一次的结果仍可能含%XX(%252e%252e→%2e%2e),二次decodeURIComponent会把它还原成../,还顺带还原%00NUL 字节。
路径穿越怎么防御
resolveDotSegments(path, opts?)解析./..段且永远不会逃逸到根/之上,结果恒为单一前导/的绝对路径(杜绝//host协议相对形态)。它按任意%25嵌套深度解码点段(%2e、%252e…)并把\归一为/,所以%2e%2e/、..\..\与字面../一样被捕获(src/utils/path.ts)。尾部./..按目录处理并保留结尾斜杠(RFC 3986 §5.2.4),内部空段保留(/a//b不变)。
两个可选参数面向下游差异:decodeSlashes让范围检查也能捕获编码分隔符(下游 nginx 会自己把%2f解回/);mergeSlashes模拟合并空段的下游。配isCanonicalPath(path, opts?)做热路径快速守卫——文档说得很直接:scope 检查里漏掉一次规范化,是绕过漏洞而非性能问题。
请求来源怎么判定
getRequestIP(event)默认取event.req.ip(连接对端,或服务器配置信任代理后解析的地址)。xForwardedFor: true改为取x-forwarded-for链的第一个条目——这正是客户端能自行写入的位置。
// 反例:getRequestIP(event, { xForwardedFor: true }) 用于限流/白名单 // → 任何调用者可自报 IP,白名单、限流、审计全部击穿 // 正例:让服务器侧信任代理(srvx trustProxy 从右往左解析),选项保持关闭getRequestHost/getRequestURL/getRequestProtocol同理,x-forwarded-*选项一律 opt-in。由Host头推导的 origin 不能用于 CSRF/origin 校验、缓存键或发给其他用户的绝对链接,除非上游已对 Host 做白名单固定。
会话与 CORS
useSession(event, config)基于 iron 加密(src/utils/session.ts、src/utils/internal/iron-crypto.ts):会话写入加密签名后的 Cookie,默认secure、httpOnly、sameSite=lax、path=/;password必须至少 32 字符且要来自密码学随机源。maxAge是绝对寿命,idleTimeout是滑动窗口(通过重 seal Cookie 前移,节流到每半个窗口一次)。getSession/updateSession/clearSession与sealSession/unsealSession分别负责读写与加解密。
handleCors(event, options)处理完整 CORS:预检请求直接附加预检头并返回204,返回值非false即已处理。origin默认"*";若传正则,注意正则不锚定——/example\.com/会匹配https://example.com.evil.test,必须写成/^https?:\/\/([a-z0-9-]+\.)?example\.com$/这种带锚点转义的形态(src/utils/cors.ts)。
场景四:代理请求——内部子请求与外部转发
三个函数层层递进(src/utils/proxy.ts):
fetchWithEvent(event, url, init?):url以/开头 → 经event.app.fetch()派内部子请求,继承过滤后的头与运行时元数据(ip、waitUntil),永不出进程;外部 URL → 原生fetch(url, init)原样发送,事件的头与上下文一律不继承(向任意主机转发 Cookie/Authorization 不安全)。proxyRequest(event, target, opts):把入站请求代理出去。请求体流式透传、不缓冲,所以此前读过 body(readBody()、readFormData()或读体中间件)会锁死流导致失败——需要"先检查再代理"就从event.req.clone()读:
app.all("/proxy", async (event) => { const body = await event.req.clone().json(); // 从克隆体检查 // ... return proxyRequest(event, target, { filterHeaders: ["cookie", "authorization"], // 对不完全信任的上游剥离凭据 }); // 原请求流完好 });默认行为值得记住:入站的Cookie与Authorization原样转发(同信任的反向代理这是对的);上游 3xx 默认透传不跟随;xfwd默认false,开启后也只把服务端视角的值追加/覆写进x-forwarded-*,客户端值永远不赢。
proxy(event, target, opts):与proxyRequest的关键差异是默认不转发入站头,只发调用方通过opts.headers显式传的头,且opts.filterHeaders对它无效。
共同红线:不要把未净化的用户输入直接当url/target——主机白名单、内部路径拦截、协议强制都是调用方的职责;/开头的内部 target 会绕过外部安全层(反向代理认证、IP 白名单、mTLS)。
场景五:WebSocket 与 JSON-RPC 通道
defineJsonRpcHandler({ methods, ... })生成实现 JSON-RPC 2.0 的 H3 处理器,内置三条安全默认(src/utils/json-rpc.ts):
- 强制 JSON
Content-Type(否则415):不强制的话,攻击者页面的 HTML 表单就构成 CORS "simple request",可带受害者 Cookie 免预检到达; - 默认拒绝跨源请求(带不匹配
Origin的请求403;无Origin的 CLI/服务端调用放行); - 批处理默认上限 50 条:每条并发派发,不封顶等于一次请求扇出成任意次方法调用,限流与配额全部失效。
app.post("/rpc", defineJsonRpcHandler({ methods: { sum: ({ params }, event) => (params as any).a + (params as any).b, }, }));defineJsonRpcWebSocketHandler(methods, hooks?)走 WebSocket:它不校验Origin——WebSocket 升级不受 CORS 约束,任意源页面都能携带访客 Cookie 建连。请在upgrade钩子里自己校验Origin,必要时抛Response中止。defineWebSocket(hooks)+defineWebSocketHandler(http?)提供纯 WS 场景,非升级请求默认426。
原理深潜:两个代表性实现
为什么限流绑定"消费"而不是预缓冲
assertBodySize的取舍见 src/utils/body.ts。预缓冲看似简单,但会把整个 body 留在内存里、阻塞流式 handler,而且拦不住撒谎偏小的Content-Length。H3 的做法是三步:先处理"诚实"的超标声明(处理前413),再拒绝走私组合(400),最后把event.req替换成limitRequestBody包装的代理,createError注入的是标准HTTPError,body 读取器与错误管线无需任何特殊映射就能把它当作已处理的413。
代价是明确写出来的:chunked/未知长度的超限只在 handler 读体时浮现,而不是进 handler 前;未被读取的 body 不计数。这是"字节精确 + 零额外内存"与"入口拦截"之间的选择,H3 选了前者,并在 JSDoc 里把两个后果都写明白。
内部子请求如何堵死//evil.com
proxy/fetchWithEvent用target[0] === "/"判定内部目标,但 URL 解析器会把更长的前导分隔符序列读成域名://evil.com/x甚至/\evil.com/x(\对特殊协议也是分隔符)。若照原样解析,一个"内部"子请求会拿着外部 origin 出发。src/utils/proxy.ts 的createSubRequest用一条正则先折叠前导序列:
// 分隔符本身(\ 也算)+ 解析前会被剥掉的 C0 字符(\t \n \r) const LEADING_SEPARATOR_RUN_RE = /^(?:[/\\]|[\t\n\r])+/; const url = new URL(path.replace(LEADING_SEPARATOR_RUN_RE, "/"), event.url);正则特意覆盖了 C0 字符,因为/\t/evil.com会被 URL 解析器先剥掉制表符、再读成//evil.com——不堵这里就是绕过。解析时以event.url为基址,内部 target 因此永远落在应用自己的 origin 上。
坑与安全边界
集中列几条最常见的误用,每条都给正确写法:
- 代理前读了 body:
readBody()之后再proxyRequest会因流被消费而失败。正确:await event.req.clone().json()检查,原流留给代理。 - 路由参数二次解码:
decodeURIComponent再解一遍会把%2e%2e变回../。正确:getRouterParams(event, { decode: true })的返回值当最终值,落文件系统前过resolveDotSegments。 - 信任
x-forwarded-*第一个值:任何xForwardedFor: true类配置都等于允许调用者自报 IP。正确:服务器侧配置信任代理(srvxtrustProxy),读event.req.ip。 - 开放重定向:
redirect(query.next)或redirectBack(event, { fallback: query.next })都是开放重定向。正确:location/fallback走白名单或硬编码路径;"回上一页"优先redirectBack的 pathname 默认行为。 - 凭据外流:向不完全信任的上游
proxyRequest会原样转发Cookie与Authorization。正确:filterHeaders: ["cookie", "authorization"];或者改用proxy/fetchWithEvent,它们默认就不继承事件头。 raw接用户输入 / 正则 origin 不锚定:前者重开 XSS,后者让*.evil.test混进 CORS 白名单。正确:raw只包可信标记,CORS 正则^…$锚定并转义点号。
生态与延伸
- 文档导航索引:docs/2.utils/0.index.md,按 Request、Response、Cookie、Security、Proxy、MCP、More、Community 八类组织;完整 API 清单即 src/index.ts 的 Utils 段。
- 可运行示例:examples/cookies.mjs、examples/cors.mjs、examples/query-params.mjs、examples/server-sent-events.mjs、examples/websocket.mjs、examples/nested-app.mjs。
- 行为基准:test/ 下与 utils 一一对应的用例,如 test/cookies.test.ts、test/proxy.test.ts、test/security.test.ts、test/json-rpc.test.ts,不确定某个默认行为时先看它们。
- 社区兼容库(Apitally、unjwt、Clear Router 等)收录于 docs/2.utils/99.community.md。
结语
H3 的价值不在功能数量,而在每个默认值都可追溯:multipart 的 opt-in 解析、限流绑定消费、路由参数单层解码、代理凭据的转发策略、内部子请求的 origin 钉死——这些边界写在源码注释与 JSDoc 里,而不是留给你踩。拿到new H3()这个轻量实例后,按需取用工具函数,就能用很少的代码跨 Node、Bun、Deno、Cloudflare 部署一个安全基线完整的服务。下一步建议通读 docs/2.utils/ 的八篇文档,再对照test/里的用例把每个默认行为记牢。
【免费下载链接】h3⚡️ Minimal H(TTP) framework built for high performance and portability项目地址: https://gitcode.com/GitHub_Trending/h31/h3
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考