关于 remix-v2-meta-sessions
用于审查 Remix v2 元数据、会话、身份验证和 CSRF 处理的参考技能,说明特定版本的 API,并为路由元数据和受保护请求提供决策步骤。
主要功能
- 01Remix v2 meta 描述符指导
- 02父级元数据合并方式
- 03会话提交与闪存消息处理
- 04loader 和 action 身份验证模式
- 05CSRF 风险与令牌指导
适用场景
- 01检查路由元数据行为
- 02核查会话持久化与闪存消息
- 03审查受保护的 loader 和 action
- 04评估变更请求的 CSRF 处理
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ existential-birds/ beagle --skill remix-v2-meta-sessions安装后在 Claude Code 中输入/remix-v2-meta-sessions确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/auth-csrf.mdlinks.mdmeta-v2.mdsessions.md详细介绍
Remix v2 Meta、会话、身份验证和 CSRF
快速参考
v2 的 meta 返回描述符对象数组——而不是 v1 的对象结构。
在过时的代码库中,v1 风格的对象字面量仍可通过类型检查,但运行时不会渲染任何标签。
// app/routes/posts.$slug.tsx
import type { MetaFunction } from "@remix-run/node";
export const meta: MetaFunction<typeof loader> = ({ data }) => {
if (!data?.post) return [{ title: "Not Found" }];
return [
{ title: `${data.post.title} | My Blog` },
{ name: "description", content: data.post.excerpt },
{ property: "og:title", content: data.post.title },
{ tagName: "link", rel: "canonical", href: data.post.url },
];
};
采用安全默认值并支持密钥轮换的 Cookie 会话存储:
// app/session.server.ts
import { createCookieSessionStorage } from "@remix-run/node";
type SessionData = { userId: string };
type SessionFlashData = { error: string };
const SESSION_SECRET = process.env.SESSION_SECRET;
if (!SESSION_SECRET) throw new Error("SESSION_SECRET is required");
export const { getSession, commitSession, destroySession } =
createCookieSessionStorage<SessionData, SessionFlashData>({
cookie: {
name: "__session",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 30,
secrets: [
SESSION_SECRET,
...(process.env.SESSION_SECRET_OLD ? [process.env.SESSION_SECRET_OLD] : []),
],
},
});
文档头部:meta 和 links
<Meta /> 和 <Links /> 必须放在 root.tsx 的 <head> 内;
<ScrollRestoration />、<Scripts /> 和 <LiveReload /> 则应放在
<body> 末尾。缺少其中任一聚合组件,都可能导致“css doesn't load”(CSS 未加载)或“meta tags missing”(缺少 meta 标签),且不会出现编译错误。
// app/root.tsx
import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react";
export default function App() {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}
<Meta /> 和 <Links /> 的聚合方式不同。 <Links /> 会遍历
整个路由匹配链,并渲染每个匹配路由导出的 links
——叶子路由中声明的样式表会自动渲染,并在导航离开时卸载。<Meta /> 不会聚合;Remix 只会选取最后一个匹配路由的 meta 数组。若要在 meta 中继承父级内容,请显式对 matches 执行 flatMap:
import type { MetaFunction } from "@remix-run/node";
import type { loader as projectLoader } from "./project.$pid";
export const meta: MetaFunction<
typeof loader,
{ "routes/project.$pid": typeof projectLoader }
> = ({ data, matches }) => {
const parentMeta = matches.flatMap((m) => m.meta ?? []);
const project = matches.find((m) => m.id === "routes/project.$pid")?.data;
return [
...parentMeta,
{ title: `${data?.task.name} | ${project?.name}` },
];
};
MetaFunction 的第二个泛型(以路由 ID 为键)会为父级路由的
matches.find(...).data 提供类型。参见
references/meta-v2.md。
会话
每个变更响应都必须将 commitSession 附加为 Set-Cookie 头。Remix 不会自动提交;调用 session.set(...) 后返回普通的 json(data) 会悄无声息地丢弃更改。
return redirect("/dashboard", {
headers: { "Set-Cookie": await commitSession(session) },
});
session.flash(key, value) 只能读取一次;读取它的 loader 仍必须在读取后调用
commitSession,以清除 flash。参见
references/sessions.md。
身份验证:在 loader 中抛出 redirect
标准模式是使用 requireUserId(request) 辅助函数,对未通过身份验证的请求抛出
redirect()。抛出的响应会短路 loader;无需顶层 return。
// app/auth.server.ts
import { redirect } from "@remix-run/node";
import { getSession } from "./session.server";
export async function requireUserId(request: Request): Promise<string> {
const session = await getSession(request.headers.get("Cookie"));
const userId = session.get("userId");
if (!userId) {
const url = new URL(request.url);
const redirectTo = `${url.pathname}${url.search}`;
throw redirect(`/login?redirectTo=${encodeURIComponent(redirectTo)}`);
}
return userId;
}
绝不要在 React 组件内部限制路由访问——受保护组件仍会进行 SSR,并向未通过身份验证的用户发送 HTML/loader 数据。参见 references/auth-csrf.md。
CSRF
Remix 没有内置的 CSRF 防护。 同源 <Form> 提交完全依赖你为会话 Cookie 设置的 SameSite 值。
SameSite=Lax 会在所有当前浏览器中阻止跨站 POST 导航携带 Cookie。(Chrome 曾在 2020 年短暂支持 2 分钟的“Lax+POST”窗口——该功能已于 2021 年移除。)真正的 Lax 与 Strict 权衡在于子域接管:使用
Lax 时,被攻陷的子域可以携带凭据发起顶层 GET 导航;使用
Strict 时,来自外部站点的深层链接导航会丢失会话。出于正当的跨站需求使用 SameSite=None 的应用(OAuth 弹窗、iframe 嵌入)完全没有 Cookie 层面的 CSRF 防护。
建议使用 remix-utils/csrf,并配备专用签名 Cookie——绝不要复用会话 Cookie。手动调用 fetch("/api/x", { method: "POST" })
会绕过 AuthenticityTokenInput,因此任何未调用
csrf.validate(request) 的 action 都是攻击者的入口。
闸门(决策顺序)
按顺序回答。通过表示条件为真;选择同一行上的 API,然后停止。
这个 meta 标签应放在哪里?
- 它是否适用于整个站点(charset、viewport、默认 OG 图片)?
- 通过 → 在
root.tsx的<head>中直接使用 JSX。这样可避免 v2 不合并的意外行为,并防止重复标签。停止。 - 未通过 → 第 2 步。
- 通过 → 在
- 它是否仅适用于特定路由(title、description、canonical、JSON-LD)?
- 通过 → 在叶子路由文件中使用
export const meta;如果需要父级值,请使用matches.flatMap((m) => m.meta ?? [])。停止。
- 通过 → 在叶子路由文件中使用
身份验证检查:loader、action 还是辅助函数?
- 这是必须保护的 GET(页面渲染)吗?
- 是 → 在
loader顶部调用await requireUserId(request)。结束。
- 是 → 在
- 这是必须保护的 POST/PUT/DELETE 变更操作吗?
- 是 → 在
action顶部调用await requireUserId(request),并调用await csrf.validate(request)。结束。
- 是 → 在
- 退出登录?
- 是 → 只能使用
action,绝不要使用loader。指向loader的<Link to="/logout">可通过<img src="/logout">发起 CSRF 攻击。请使用<Form method="post" action="/logout">。结束。
- 是 → 只能使用
CSRF 令牌存放在哪里?
- 你在使用
remix-utils/csrf吗?- 是 → 使用专用的
createCookie("csrf", { ... })Cookie,与会话 Cookie 分开。CSRF 值是经过签名的字符串;会话值是序列化对象——重复使用同一个 Cookie 会导致验证时报错。结束。 - 否 → 转到第 2 步。
- 是 → 使用专用的
- 没有 CSRF 库?
- 是 → 说明威胁模型;会话 Cookie 必须设置
sameSite: "strict",并在每个 action 中验证Origin标头。建议改用remix-utils/csrf。
- 是 → 说明威胁模型;会话 Cookie 必须设置
其他文档
- Meta v2:关于描述符类型、通过
matches合并父级数据、JSON-LD,以及 v1→v2 迁移中的注意事项,请参阅 references/meta-v2.md。 - Links:关于样式表、预加载、dns-prefetch,以及
<Links />的父级聚合行为,请参阅 references/links.md。 - Sessions:关于
createCookieSessionStorage配置、commitSession/destroySession模式、Flash 消息以及数据库支持的会话,请参阅 references/sessions.md。 - 身份验证和 CSRF:关于
requireUserId辅助函数、登录/退出登录 action、remix-auth以及remix-utils/csrf的配置,请参阅 references/auth-csrf.md。
v1 与 v2 快速对比
| 注意事项 | v1 | v2 |
|---|---|---|
meta 返回形式 | 对象 { title, description } | 数组 [{ title }, { name, content }] |
| 父级 meta 合并 | 自动合并(每个键以后写入的值为准) | 不合并;仅使用最后一个匹配的路由 |
父级数据的 meta 参数 | parentsData | matches(需手动使用 flatMap) |
| OG 标签 | { "og:title": "..." } 简写 | { property: "og:title", content: "..." } |
| 迁移标记 | v2_meta: true future flag | N/A(v2 默认) |