H3 条件缓存与更多请求工具

2026-10-30 17:48 更新

这一节是请求侧剩下的全部工具:条件缓存、方法断言、取 IP 与 Host、读路由参数、以及几个底层请求构造器。它们的共同点是都在解决「怎么安全地读请求信息」,所以每个函数下面都能看到一条安全说明。

条件缓存:handleCacheHeaders

协商缓存的标准流程是「客户端带上 If-None-Match 或 If-Modified-Since,服务端判断没变就回 304」。这个函数把两头都做了:

import { H3, handleCacheHeaders } from "h3";

const app = new H3().get("/data.json", (event) => {
  const etag = "v42";
  // 命中就回 304(空体),没命中返回 false,继续走下面的正常响应
  const notModified = handleCacheHeaders(event, {
    etag,
    lastModified: new Date("2026-10-01"),
    maxAge: 60,
  });
  if (notModified) {
    return notModified;
  }
  return { version: 42 };
});

三个细节容易被忽略:

  1. public 是默认加的,但只要你自己传了 private 或 no-store,它就不会再加——所以 cacheControls: ["private"] 不会产出矛盾的 public, private。
  2. 一次调用同时负责「读条件头」和「写缓存头」,不要只写一半。
  3. 它返回的是响应或 false,必须自己判断并按需 return,属于 6.1 说的第三类工具。

缓存隔离:withCacheIsolation

这个函数解决的是一类很隐蔽的 bug:别人设置的头被缓存下来,然后回放给所有用户。

场景是这样的——全局中间件在 event.res 上暂存了某个头(比如根据用户身份加的 x-user-id),而缓存未命中时,缓存库会把 handler 结果连同这些暂存头一起存进缓存条目;之后所有命中该条目的请求都会带着这个头返回。一个人触发写入,所有人拿到他的数据。

import { handleCacheHeaders, toResponse, withCacheIsolation } from "h3";
import { defineCachedHandler } from "ocache";

// 用 withCacheIsolation 包一层:暂存头不再进入缓存条目
const cached = withCacheIsolation(
  defineCachedHandler(() => "hello", {
    maxAge: 60,
    toResponse: (value, event) => toResponse(value, event),
    handleCacheHeaders: (event, conditions) => handleCacheHeaders(event, conditions),
  }),
);

app.get("/cached", cached);

它的处理规则很讲究,值得逐条看:

头 处理方式
请求期暂存的普通头 从事件上摘下来,命中与未命中都单独合并到响应,不进缓存
暂存的 Cache-Control、Vary(origin 除外) 保持可见给缓存,否则「不该被缓存」的判断会失效
暂存的 private / no-store 替换缓存 handler 自己设的值,让前面的 CDN 也不存
Vary: Origin 不共享,因为 CORS 头本来就是按请求暂存的
错误响应(4xx/5xx) 不合并暂存头,与 H3 默认行为一致
被包装 handler 的属性 invalidate、expire、resolveKeys 一并复制到返回的 handler

一句话概括:决定「能不能缓存」的头让它看见,决定「这个响应长什么样」的头拦在外面。

方法断言:assertMethod 与 isMethod

import { H3, assertMethod, isMethod } from "h3";

const app = new H3().all("/item", (event) => {
  // 不满足就抛 405,响应里自带 Allow 头(RFC 9110 要求)
  assertMethod(event, "GET");
  return "get";

  // 也可以用 isMethod 自己分支:不抛错,只返回布尔
  // if (isMethod(event, ["POST", "PUT"])) { ... }
});

两个函数都支持第三个参数 allowHead:为 true 时,期望 GET 的检查也放行 HEAD——因为 HEAD 本来就该走 GET 的逻辑,只是响应体被剥掉。

isMethod 与 assertMethod 的区别就是 6.1 那条命名规律:is* 不抛错,assert* 抛 405。

取请求信息:四个函数,三层风险

这四个函数一起用才能拼出完整的请求 URL,而它们都有同一个设计:转发头默认不信。

import { H3, getRequestHost, getRequestIP, getRequestProtocol, getRequestURL } from "h3";

const app = new H3().get("/whoami", (event) => {
  // 默认读 Host 头;xForwardedHost: true 才看 x-forwarded-host
  const host = getRequestHost(event);

  // 默认读运行时解析出的 event.req.ip,而不是 x-forwarded-for
  const ip = getRequestIP(event);

  // 默认 http;xForwardedProto: true 才看 x-forwarded-proto
  const protocol = getRequestProtocol(event);

  return { host, ip, protocol, url: getRequestURL(event).href };
});

为什么要默认不信?因为这三个头都是客户端可伪造的:

参数 打开后读什么 风险
xForwardedHost: true x-forwarded-host 影响绝对链接、缓存键、同源判断
xForwardedProto: true x-forwarded-proto(多值取第一个) 影响生成的 URL 协议
xForwardedFor: true x-forwarded-for 的第一个条目 那条就是客户端自己写的,等于让调用方自选 IP

IP 这条最值得展开:代理通常追加到链尾(nginx 的 $proxy_add_x_forwarded_for、多数 CDN 都是),所以客户端自己发的那条永远在最左边。取第一条,等于任何调用者都能伪装成任意 IP,IP 白名单、限流、地域判断、审计日志全部失效。正确做法是让服务器信任代理(srvx 的 trustProxy 从右往左跳过可信跳数),把 xForwardedFor 留着别开。

getRequestHost 与 getRequestURL 还有一条:返回的 .host、.origin 来自可伪造的头,不要用于安全判断(CSRF 校验、缓存键、生成发给别人的绝对链接),除非上游已经把 Host 固定成白名单。

请求指纹:getRequestFingerprint

给同一次「客户端 + 请求」生成一个稳定标识,典型用途是限流和去重。它把若干组件拼成一个字符串再哈希(默认 SHA-256),关键是组件默认几乎只含 IP,其余一律 opt-in:

选项 默认 含义
ip true 用 getRequestIP 的结果(同样默认不信 x-forwarded-for)
xForwardedFor false 打开后改用 x-forwarded-for 的第一条——会重蹈上面 IP 伪造的坑,一般不开
method false 把 event.req.method 纳入指纹
url false 把 event.req.url 纳入指纹
userAgent false 把 user-agent 纳入指纹
hash "SHA-256" 改成 "SHA-1"/"SHA-384"/"SHA-512",或 false 直接返回拼接串(不哈希)

import { H3, getRequestFingerprint } from "h3";

const app = new H3().get("/api/limited", async (event) => {
  // 默认只按 IP 限流;想更细可以开 method / url
  const fp = await getRequestFingerprint(event, { ip: true });
  if (!fp) {
    return; // IP 取不到(无代理又没运行时信息)时为 null
  }
  // const count = await rateLimit.consume(fp);
});

两点提醒:返回值是 Promise<string | null>,IP 取不到且其余都没开时会得到 null;这个工具标注了 @experimental,行为未来可能调整,别把它当稳定契约写进核心逻辑。

读路由参数:只解码一次

import { H3, getRouterParam, getRouterParams } from "h3";

const app = new H3().get("/files/**:rest", (event) => {
  // 默认返回原样:仍然是百分号编码的字符串
  const raw = getRouterParams(event); // { rest: "%252e%252e/x" }

  // decode: true 只解一层:%25 解成 %,所以还剩 %2e%2e
  const once = getRouterParams(event, { decode: true }); // { rest: "%2e%2e/x" }

  // 取单个参数
  const rest = getRouterParam(event, "rest");
  return { raw, once, rest };
});

三条规则必须记住:

  1. 编码的路径分隔符永远不解(%2f、%5c,任意嵌套深度)。所以参数里不可能凭空多出一个 /,路由按一段匹配的,就还是一段。
  2. 只解一层。%25XX 解出来是字面上的 %XX,结果里仍然可能带转义——包括点段和控制字符(%2500 → ``)。
  3. 绝对不要再解一次。第二次 decodeURIComponent 会把 %2e%2e/x 变成 ../x,把 `` 变成 NUL 字节——这正是路由和中间件从没见过的穿越。

要拿解码后的参数做文件系统或上游路径,用 resolveDotSegments 规范化(6.6),而不是继续解码。

带校验地取查询与参数

import { H3, getValidatedQuery, getValidatedRouterParams } from "h3";
import { z } from "zod";

const app = new H3().get("/users/:id", async (event) => {
  // query 与 params 各有一个校验版本,用法与 readValidatedBody 一致
  const query = await getValidatedQuery(event, z.object({ page: z.coerce.number().default(1) }));
  const params = await getValidatedRouterParams(event, z.object({ id: z.string().uuid() }));
  return { page: query.page, id: params.id };
});

getValidatedRouterParams 同样支持 decode: true 与 onError,语义与 getRouterParams 完全一致。

三个底层构造器

平时用不到,写适配层或测试时会遇到:

函数 作用
toRequest(input, options?) 把字符串 / URL / Request 统一转成标准 Request,相对路径会按 host 补全
requestWithURL(req, url) 只替换 URL 的轻量代理,不克隆整个请求(省一次分配)
requestWithBaseURL(req, base, opts) 剥掉 base 前缀的轻量代理,opts.url 建议传 event.url

toRequest 有一条安全说明值得记:host 头只用来当合成 URL 的 authority(缺失或畸形时退回 localhost),永远不会影响路径,而且 x-forwarded-proto 被忽略,协议固定为 http。想控制 origin 就传绝对 URL。

requestWithBaseURL 的 opts.url 为什么要传 event.url?因为非规范路径下 req.url 是线格式、event.url 是归一化格式,用其中一个的偏移去切另一个,正是挂载前缀错位的根源(4.2 讲过)。

排查表

现象 最可能原因 处理
304 一直不生效 只调了 handleCacheHeaders 但没 return 它的结果 判断返回值后 return
CDN 缓存了带用户信息的响应 暂存头进了缓存条目 用 withCacheIsolation 包住缓存 handler
405 响应没有 Allow 头 手写了状态码而没用 assertMethod 改用 assertMethod
拿到的 IP 全是同一个 前面有代理,但没配信任 用 trustProxy,别开 xForwardedFor
路由参数里出现 ../ 自己又 decode 了一次 只解一层,需要规范化用 resolveDotSegments
生成的绝对链接域名不对 Host 头被伪造 用白名单校验或固定配置

延伸阅读

  • 路由参数的归一化与匹配规则:见 2.2 路由
  • event.url 与 event.req.url 的区别:见 4.2 H3Event 事件对象
  • CORS 与路径规范化:见 6.6 安全工具
  • 路由规则自带的缓存(cache / swr):见 3.2 路由规则缓存
以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号