H3 安全工具

2026-10-30 17:48 更新

安全工具分四组:认证、会话、CORS、路径规范化。前三组是常见的 Web 安全需求,第四组最容易被低估——它决定了「守卫看到的那条路径」和「下游实际处理的那条路径」是不是同一个字符串。

Basic 认证

import { H3, serve, basicAuth } from "h3";

const app = new H3();

// 用法一:当中间件挂,挂在哪个路由就保护哪个路由
const auth = basicAuth({ password: "test" });
app.get("/", (event) => `你好,${event.context.basicAuth?.username}`, [auth]);

serve(app, { port: 3000 });

import { defineHandler, requireBasicAuth } from "h3";

// 用法二:在 handler 里对当前请求即时校验,失败抛 401
export default defineHandler(async (event) => {
  await requireBasicAuth(event, { password: "test" });
  return `你好,${event.context.basicAuth.username}`;
});

通过校验后,用户名与 realm 会写进 event.context.basicAuth。注意 basicAuth 返回的是中间件(要放进路由的 middleware 选项或 app.use),而 requireBasicAuth 是在 handler 内部执行的断言——前者能挡住整个路由,后者只在你调用的那一行之后生效。

⚠️ Basic 认证的密码是 Base64 明文传输,只在 HTTPS 下有意义。它的定位是「内网工具/临时保护」,不是面向公网的登录方案。

会话:三种入口,行为差别很大

函数 何时写 Set-Cookie 适用
getSession(event, config) 只在 updateSession 之后 只读场景(鉴权判断、读偏好)
useSession(event, config) 立即,无会话也新建并持久化 需要稳定 session id
updateSession(event, config, update?) 立即 写入/修改后

这个差别有实际影响:用 getSession 读一次不会给匿名访客发 cookie,所以「检查是否登录」不会在生产环境凭空造出成千上万个空会话。但它的 id 只有被写入后才跨请求稳定——需要稳定的匿名标识就用 useSession。

import { H3, getSession, updateSession, clearSession, sealSession, unsealSession } from "h3";

const app = new H3()
  .get("/read", async (event) => {
    // 只读:匿名访客不会收到 Set-Cookie
    const session = await getSession(event, { password: "session-secret" });
    return { user: session.data.user ?? null };
  })
  .post("/write", async (event) => {
    // 写入:此时才会下发 cookie
    await updateSession(event, { password: "session-secret" }, { user: { id: 1 } });
    return { ok: true };
  })
  .post("/logout", async (event) => {
    await clearSession(event, { password: "session-secret" });
    return { ok: true };
  });

sealSession / unsealSession 是底层能力:前者加密并签名会话数据,后者解密并验签。只有你要自己管理会话载体(比如存进请求头、而不是 cookie)时才需要直接调用它们。

CORS:契约是「返回值」

import { H3, handleCors } from "h3";

const app = new H3().all("/api/**", (event) => {
  const corsRes = handleCors(event, {
    origin: "https://example.com",
    methods: ["GET", "POST"],
    // 预检直接返回 204
    preflight: { statusCode: 204 },
  });

  // 关键:不是 false 就代表已经处理完(预检已被应答),必须返回
  if (corsRes !== false) {
    return corsRes;
  }

  return { ok: true };
});

判断规则一句话:预检请求会被就地应答并返回一个响应;普通请求只加头,返回 false。所以忘了判断返回值,表现就是「预检通了,真实请求的 CORS 头却没加」或者反过来。

配套的几个函数:

函数 用途
appendCorsHeaders(event, options) 只追加实际响应所需的 CORS 头
appendCorsPreflightHeaders(event, options) 只追加预检所需的头
isPreflightRequest(event) 判断是不是预检(OPTIONS + Access-Control-Request-Method)
isCorsOriginAllowed(origin, options) 单独判断某个 origin 是否在白名单里

💡 如果用路由规则写 CORS(routeRules({ "/api/**": { cors: true } })),就不要同时手写 handleCors——两套机制都在改 Access-Control-* 头,后设的会覆盖先设的,排查起来非常费劲。选一套。

路径规范化:三个函数的分工

这三个函数常被混用,实际职责很清晰:

函数 作用对象 用途
normalizeRoute(route) 路由模式(注册时) 保证注册的字符串与 event.url.pathname 同构
resolveDotSegments(path) 请求路径(运行时) 解析 ./..、反斜杠、嵌套转义,做作用域判断
isCanonicalPath(path, opts) 请求路径 快路径判断「已经是规范形式了吗」

normalizeRoute 是幂等的,做了四件事:补前导 /、把请求路径里必然编码的字符编码(/café/** → /caf%C3%A9/**)、解掉多余转义(/%40handle → /@handle,但 %2F、%25 保持编码)、解析点段。rou3 的语法字符(?、{、}、^、\)原样保留——要匹配这些字符本身,必须写成百分号编码。传绝对 URL 会抛错,因为路由模式永远是路径。

resolveDotSegments 的保证更硬:

import { resolveDotSegments } from "h3";

resolveDotSegments("/pub/%252e%252e/private/x"); // "/private/x":嵌套转义的点段也会被解开
resolveDotSegments("/a/b/../c"); // "/a/c"
resolveDotSegments("/..\\..\\etc/passwd"); // "/etc/passwd":反斜杠归一为 /
resolveDotSegments("//host/x"); // "/host/x":前导斜杠压成一个,不可能变成协议相对地址
resolveDotSegments("/a/b/.."); // "/a/":指向目录,保留尾斜杠

四条边界值得记:永远不会越过根(结果一定是单个前导 / 的绝对路径);%2f/%5c 默认不解(可用 decodeSlashes 打开);内部空段保留(/a//b 不合并,除非开 mergeSlashes);其他转义(%20、非 ASCII)一律不动,保证结果与 event.url.pathname 处于同一种表示。

isCanonicalPath 只是「省一次调用」的优化:它返回 true 当且仅当 resolveDotSegments 会原样返回。用它时必须传与后面 resolve 相同的选项,检查一种模式、解析另一种模式,等于让保证失效。

组合起来的正确用法是这样:

import { resolveDotSegments } from "h3";

// 作用域校验:先把两侧都规范化,再比较前缀
function isInsideScope(requestPath: string, allowedPrefix: string): boolean {
  const path = resolveDotSegments(requestPath);
  const prefix = resolveDotSegments(allowedPrefix);
  // 加尾斜杠,避免 /private 命中 /private-backup
  return path === prefix || path.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`);
}

排查表

现象 最可能原因 处理
预检通了,真实请求缺 CORS 头 忘了判断 handleCors 返回值 按上文模板写
CORS 头重复或互相覆盖 同时用了路由规则与 handleCors 只保留一套
匿名访客收到一堆空会话 cookie 用了 useSession 做只读判断 改 getSession
静态文件守卫被 .. 绕过 只做了字符串比较 用 resolveDotSegments 规范化
/private-backup 能访问到 /private 的规则 前缀比较没加边界 前缀补尾斜杠再比
isCanonicalPath 报 true 但后续仍出问题 检查与解析用了不同选项 两处选项保持一致
路由注册后匹配不到 模式没走 normalizeRoute 用同一个函数规范化

延伸阅读

  • 路由参数的一次解码语义:见 6.3 条件缓存与更多请求工具
  • 路由规则的 cors 与 headers:见 3.1 路由规则入门
  • 静态资源服务的路径规则:见 6.4 响应工具
  • 会话在中型项目里的完整落地:见 7.3 会话基础
以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号