H3 安全工具
安全工具分四组:认证、会话、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 会话基础

TRAE-AI编程


更多建议: