H3 路由规则类型与安全
前面三节讲怎么配规则。这一节讲两件容易被跳过、但真出问题代价最高的事:类型怎么写才不会写错键名,以及为什么同一条路径换个编码就能绕过你的规则。
后者不是理论风险。规则的匹配基于字符串,而同一个资源在 URL 里有多种合法写法——攻击者只要把 %2f、%40、十六进制大小写换一换,就能让一条本该命中的规则不命中。
一、两个接口,别混用
| 接口 | 描述的是 | 在哪用 |
|---|---|---|
RouteRuleConfig |
你写的配置 | routeRules({...}) 的入参 |
RouteRules |
匹配合并后产出的值 | event.context.routeRules、匹配器返回值、处理器的 options |
自定义规则要在两个接口里写同样形状:
declare module "h3/rules" {
interface RouteRuleConfig {
/** 增量静态再生成(构建期处理)。 */
isr?: number | boolean;
/** 把这条路由加入预渲染队列。 */
prerender?: boolean;
/** 纯数据规则,没有运行时处理器。 */
audience?: "public" | "internal";
}
interface RouteRules {
isr?: number | boolean;
prerender?: boolean;
audience?: "public" | "internal";
}
}
// 之后 event.context.routeRules.audience 的类型是 "public" | "internal" | undefined
三个要点:
RouteRuleConfig是封闭的——不认识键就报类型错误。所以redirct这种拼写错误会在编译期被抓住,而不是上线后静默不生效。要加键只能做模块增强。RouteRules决定所有「产出位置」的类型:上下文、匹配器返回值、每个命中规则传给处理器的options。- 数据型规则在运行时原样透传,模块增强只影响类型。
还有一个容易踩的细节:RouteRules 由 h3 拥有并在 h3/rules 再导出,所以 declare module "h3" 增强的是同一个声明——Nitro 和独立的 h3-rules 包都会跟着变。而 RouteRuleConfig 只存在于 h3/rules。
重新声明内置规则
框架要给某个内置键换一套形状时,可以重新声明——是替换,不是交叉:
// 重新声明内置键:替换 h3 自带的类型(不是交叉),用于框架给同一键换形状
declare module "h3" {
interface RouteRules {
redirect?: string | { to: string; status?: number };
cors?: boolean;
}
}
替换后的形状可以是任意类型,包括原始类型和 false,RouteRules 本身没有约束。内置定义单独放在 BuiltinRouteRules,只补给没有被重新声明的键;合并后的完整上下文类型导出为 ResolvedRouteRules。
好处是:你没重声明的内置键仍保留精确的选项类型,所以 rules.redirect?.to 这种写法不需要额外收窄。
为什么没有索引签名
H3 只组合自己的内置键,不声明 [key: string]: unknown。原因很实际:一旦共享接口上有了这条兜底索引,其他所有模块的增强都会变成类型错误。代价是——没声明的数据型键在运行时读得到,但类型上不存在,直到你声明它。
另外两个导出给集成用:NormalizedRouteRules(一个模式经 normalizeRouteRules() 后的形状,含 false 重置与任意规则名)与 MatchedRouteRule(带来源的合并规则,也就是处理器收到的那个)。
二、安全:一条路径有多种写法
event.url.pathname 只在解码后仍能通过 URL 序列化时才解码。比如 /%40admin 会被当作 /@admin 提供;而分隔符(%2f、%5c)、任意深度的 %25、序列化器会重新编码的值(%20、非 ASCII)、以及 C0 控制符,都保持编码状态。
但规则模式通常是用字符本身写的。为了防止「换个编码就绕过」,H3 会拿每个请求再匹配一遍它的其他解读:
| 解读 | 含义 |
|---|---|
| canonical | 编码的分隔符被解码、. 与 .. 被解析 |
| slash-merged | 下游(如 nginx 的 merge_slashes)会解析成的样子 |
| percent-decoded | 百分号解码后的样子 |
| 十六进制大写 / 小写 | 十六进制大小写在 URL 里没有意义(RFC 3986 §6.2.2.1) |
routeRules({
"/@admin/**": { redirect: "/elsewhere" },
"/a admin/**": { redirect: "/elsewhere" },
});
// GET /@admin/data → 命中
// GET /%40admin/data → 命中(h3 本来就按 /@admin/data 提供)
// GET /a%20admin/data → 命中(后端代理可能解析成 "/a admin/data")
// GET /a%2520admin/x → 命中(解两次的后端也一样)
// GET /admin%2fpanel → 同时按 /admin/panel 匹配
替代解读只能加规则,或用同等 / 更具体的模式覆盖。一条构造出来的路径,永远不能用一个更宽的模式把已命中的规则削弱。
规则键也会被归一化
配置期的键与 h3 的路由模式按同一套规则归一化,所以「用同一个字符串注册的路由」和「用同一个字符串写的规则」覆盖的请求集合完全一致:
- 解码后能存活的转义会被解码:
"/%40admin/**"就是/@admin/**。编码的 rou3 元字符会变成元字符——"/a/%3Aid"是:id参数模式,"/f/%2A%2A"是 catch-all。 - 会被序列化器编码的原始字符会被编码:
"/a admin/**"与"/a%20admin/**"是同一个模式。正则约束里也一样:"/(café|tea)/**"变成/(caf%C3%A9|tea)/**。 .与..段被解析。- 其他转义一律按字面匹配,包括
%2f、%5c、%25,以及%7B、%7D、%3F(它们否则会变成 rou3 的分组或可选语法)。要匹配{q}这类语法请写原字符,写编码形式就只是字面量。
⚠️ 十六进制大小写是个坑:小写写的键(
"/caf%c3%a9/**")会给/café/x加上自己的规则,但它的false重置只对同样写法的请求生效。想用重置把某条路由从更宽的规则里豁免出来,键就按路由的写法写,或用原字符。
三、false 重置与 restricting
false 重置在「多种路径解读」下需要特殊处理:重置是「移除一条规则」,如果处理不当,它看起来就跟「规则从未匹配」一样,于是另一条更宽的替代解读又能把它加回来。
H3 按规则是「授权」还是「限制」来分:
| 类别 | 行为 | 谁属于 |
|---|---|---|
| 允许行为(permit) | 保持重置,除非某个替代解读用「等于或比所有重置它的模式都更具体」的模式再次匹配 | 全部内置规则:cors、redirect、headers、cache、proxy |
| 限制行为(restrict) | 重新加回(失败时倾向于关闭,即 fail-closed) | 需要自己声明的自定义门 |
处理器用 RuleHandler.restricting 声明自己属于哪类,默认 false,内置规则都没有设它。
❗ 自定义的限制型处理器(鉴权门、限流、IP 白名单)必须设
restricting: true。否则在某个解读上的一次false重置,会让它在其他解读上一起失效——因为默认值是「授权」的安全选择,对「限制」恰好是错的那个,而且不会有任何警告。
完全没处理器的数据型规则无法携带这个标记。下游(如 Nitro)把它当门用时,必须自己在使用点做判断。
更窄的模式能在每种解读上压过更宽的重置,所以编码的分隔符没法剥掉解码路径应得的规则:
routeRules({
"/docs/**": { headers: false },
"/docs/x/**": { headers: { "content-security-policy": "default-src 'self'" } },
});
// GET /docs/x/y → 有 CSP
// GET /docs/x%2fy → 有 CSP(按单段提供,只命中 /docs/**,但解码解读 /docs/x/y 命中 /docs/x/**)
// GET /docs/a%2fb → 无头(/docs/a/b 不在 /docs/x/** 下)
反方向(放行)也一样成立:替代解读代表的是「下游解码后真正提供的路径」,所以为那条路径写的授权也会生效:
routeRules({
"/api/**": { cors: false },
"/api/public/:file": { cors: { origin: ["https://public.example"] } },
});
// GET /api/public/secret → 带 CORS
// GET /api/public%2fsecret → 带 CORS(解码解读是 /api/public/secret)
// GET /api/public%2fa%2fb → 不带(/api/public/a/b 段数超出 :file)
这条也覆盖编码的点段:/api/x%2f..%2fpublic%2fy 会被读作 /api/public/y,拿到同一份 CORS(含 credentials: true 时的凭据),而 h3 实际把它分发到 /api/:id。如果你的 handler 不能接受「用编码分隔符来拿解码路径的授权」,就在 handler 或中间件里直接拒绝。
被重新加回的 redirect / proxy 规则绝不会转发到作用域之外:它会按段数移除模式前缀,而分隔符被编码时段数不一样,所以这类请求直接 400(见下一节)。
每个重置过某规则的模式,在任何解读加回之前都会被统计,所以结果不依赖检查顺序。用 preMerge 编译时,重置记录在最具体的那层,只会更保守——它可能保住一次默认匹配器会撤销的重置,但绝不会反过来。
还有一条故意偏保守的残余:重置权限只会从「被重置模式所包含的模式」加回来,即使另一种解读其实是同一路径的另一种拼写。例如 {"/**": { cors }, "/app/:page": { cors: false }} 会保留 /app/a%2fb 的豁免(它的解码解读是两段)。这里所有判错的方向都是「少给权限」。
四、HEAD 与预检
HEAD 由命中的 GET 路由提供服务(RFC 9110),所以作用在 GET 上的规则也作用于 HEAD——否则一个键为 GET /admin/** 的规则可以被 HEAD 绕开。显式写 HEAD 键仍会覆盖(并可以重置)GET 的规则。
CORS 预检以 OPTIONS 到达,所以作用在「浏览器在 Access-Control-Request-Method 里声明的方法」上的 cors 规则,也会为预检解析一次。但只取 cors 这一条——其他方法限定规则一律不带,因为浏览器发预检时不带凭据,把一道门从里面拎出来会导致所有预检被拒。
五、短路响应上没有 headers
headers 在 order -1,包住它内层的所有规则,但包不住在它外层执行的规则。一条由外层规则产生的响应(cors 的预检应答、-2 位上的自定义处理器)会在进入 headers 中间件之前就短路,于是 headers 规则不会作用于它。其余响应都会经过它,包括在更内层抛出的错误响应(404、handler 抛错)。
如果某个头必须出现在这类响应上,两个动作一起做:在 routeRules() 之前注册全局中间件设置它,并同时设到 event.res.errHeaders 上——错误响应是从那个袋子构造的,不是从 event.res.headers。
六、重定向与代理的目标安全
/** 目标会把匹配到的尾巴追加到目标上,然后与目标自身基路径比对;会逃逸的请求(例如用编码的 ..%2f 做穿越)返回 400。
H3 按段数移除规则模式的前缀来得到尾巴,所以该前缀在每次匹配里必须是同样的段数。可选参数(/:lang?/old/**)与跨越分隔符的分组(/x{/a}?/old/**)会让段数变化,H3 直接 400,而不是转发删错前缀的路径。普通参数、正则参数、单段内分组都没问题;* 是 catch-all,/x/*/old/** 这类键在创建时就被拒绝。
七、CORS 凭据
credentials: true 要求显式 origin(白名单或校验函数)。与通配 origin 组合会在启动时抛错——因为带凭据的请求不能用 Access-Control-Allow-Origin: *。
八、构建期编译器
框架与构建工具可以把规则集编译成 findRouteRules,从而把 rou3 从运行时产物里拿掉:
import { compileRouteRules } from "h3/rules/compiler";
const mod = compileRouteRules(config, {
preMerge: true, // 可选:把预合并链烘进生成的匹配器
});
mod.code; // 完整模块(也可用 String(mod) 或模板字符串)
返回三种形态:
| 字段 | 内容 |
|---|---|
imports |
处理器 import 语句 |
body |
export const findRouteRules = … 声明 |
code |
完整模块(等于 String(mod)) |
编译入口会自动归一化输入,所以可以直接传「手写的配置」;已归一化的规则也能传,因为归一化是幂等的。
运行时用 createMatcherFromFind(findRouteRules) 把函数变成匹配器,再包 memoizeRouteRulesMatcher 开启记忆化。编译产物与运行时匹配器返回的结果一致。
⚠️ 匹配器 API 收到的方法必须已经大写:
findRouteRules、createMatcherFromFind、createRouteRulesMatcher都按传入值比较。routeRules()中间件会替你归一化,手写包装器则要自己event.req.method.toUpperCase()——否则小写方法连方法限定规则都命中不了,连带跳过它的门。
📌 规则选项会以 JS 对象字面量嵌入产物,所以必须能过 JSON 往返。函数、
Date、RegExp(例如cors.origin的校验函数)会让编译显式报错,而不是悄悄和运行时不一致。
特异性守卫(Specificity Guard)
createMatcherFromFind 默认带一道守卫:当某条路径的替代解读(canonical / slash-merged / percent-decoded)解析出的规则与服务路径不同时,替代解读只能用同等或更具体的模式覆盖——所以宽泛的 /** 永远无法在构造的 %2f / %2e%2e 路径上削弱更窄的 /admin/**。
默认守卫零依赖,编译产物不含 rou3。它按模式形状判断包含关系,是精确关系的保守近似:只允许它证明得了的包含,因此不会放过精确关系会拒绝的覆盖——但会更严格。判不了的情况(命名 catch-all 如 **:rest、正则或部分参数、修饰参数如 :page? / :path*)它会保留服务路径解析出的规则,而不套用更窄的。
同一个谓词也决定替代解读何时能把被重置的权限加回来:只允许「等于或比所有重置它的模式都更具体」的模式。这里过严会保留重置(安全),过松会撤销豁免——所以自定义谓词绝不能声称自己证明不了的包含关系。
规则键用到修饰参数时,建议用 matcher: true 让编译器把精确关系烘进产物;也可以传自己的谓词,或传 () => true 关掉守卫(关掉也意味着任何替代解读都能把 false 重置掉的权限加回来)。
按具体性给匹配层排序不走这个谓词,用的是构建时算好的等级,所以没烘关系的编译产物,解析结果仍与运行时一致。
匹配器导出与处理器来源
matcher: true 让产物额外导出一个可直接使用的匹配器:
compileRouteRules(config, { matcher: true });
// → export const matcher = createMatcherFromFind(findRouteRules, /* 烘好的守卫 */);
compileRouteRules(config, { matcher: { name: "routeMatcher", memoize: true } });
// → 同时包上 memoizeRouteRulesMatcher
matcher 传字符串可改名,传 { name?, memoize? } 可顺带记忆化(memoize: { max } 调上限)。memoizeRouteRulesMatcher 只在设了 memoize 时才被导入,没开的产物仍能把它摇掉。
生成的模块只导入规则集真正用到的处理器。大部分内置项是 h3/rules 的命名导出(headers、redirect、cors),而 cache 来自 h3/rules/cache、proxy 来自 h3/rules/proxy——没有对应规则时,它们的依赖不会进产物。
排查表
| 现象 | 原因 |
|---|---|
换个编码(%2f、%40)就能绕过鉴权规则 |
自定义限制型处理器漏了 restricting: true |
| 预检请求返回 401 | 把鉴权门写成了方法限定规则,预检不带凭据 |
| 预检响应缺自定义头 | 它由外层规则短路,没经过 headers 中间件;改在 routeRules() 前的全局中间件 + errHeaders 里设 |
| 编译报错说选项无法序列化 | 规则里放了函数 / Date / RegExp |
| 编译产物里方法限定规则全失效 | 匹配器收到的方法没大写 |
| 带凭据的跨域请求被拒 | credentials: true 配了通配 origin,启动时本该抛错,检查是否被捕获吞掉 |
延伸阅读
- 规则匹配与合并:见 3.1
- 缓存键与凭据剥离:见 3.2
- 代理目标安全与自定义处理器:见 3.3
- 路由本身的模式归一化:见 2.2 路由

TRAE-AI编程


更多建议: