H3 路由规则类型与安全

2026-10-30 15:39 更新

前面三节讲怎么配规则。这一节讲两件容易被跳过、但真出问题代价最高的事:类型怎么写才不会写错键名,以及为什么同一条路径换个编码就能绕过你的规则。

后者不是理论风险。规则的匹配基于字符串,而同一个资源在 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

三个要点:

  1. RouteRuleConfig 是封闭的——不认识键就报类型错误。所以 redirct 这种拼写错误会在编译期被抓住,而不是上线后静默不生效。要加键只能做模块增强。
  2. RouteRules 决定所有「产出位置」的类型:上下文、匹配器返回值、每个命中规则传给处理器的 options。
  3. 数据型规则在运行时原样透传,模块增强只影响类型。

还有一个容易踩的细节: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 路由
以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号