功能说明
createCsrfToken 生成由随机数与 HMAC 签名组成的令牌,verifyCsrfToken 检查请求头令牌与 Cookie 令牌完全相同,并验证签名确实绑定当前会话。csrfCookie 输出适合双提交模式的 Cookie 头。该方案适合传统表单、同源前端与轻量 API,能在不依赖服务端会话存储的情况下增加跨站请求伪造的难度。
关键代码
令牌由十八字节随机数的 base64url 编码、点号与 HMAC-SHA256 签名组成。签名内容为会话标识加随机数,因此攻击者无法把其他会话的令牌移植过来。校验先比较请求头与 Cookie 的字节长度和内容,再拆出随机数重算签名。所有比较使用 timingSafeEqual,长度不等时提前返回,避免抛出异常。Cookie 使用 SameSite=Lax 和 Secure。
使用注意
双提交依赖浏览器按域发送 Cookie,若存在子域可控或 Cookie 注入风险,应使用 __Host- 前缀并严格限定域。令牌必须同时出现在请求头和 Cookie 中,且服务端不能只信任 Cookie。Cookie 需要被前端脚本读取以写入请求头,因此不能设置 HttpOnly。该方案不能替代 SameSite 与 Origin 校验,高敏感操作应叠加二次确认。
验证方式
测试正常签发后校验通过,篡改随机数、签名、请求头或 Cookie 任一字段都失败。用不同会话标识验证令牌不可跨会话复用。测试缺失字段、空字符串、无点号、长度不一致与签名长度异常均返回 false。检查 Cookie 头包含 Path、SameSite 与 Secure,并确认关闭 Secure 时仅用于本地开发。
代码示例
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
export interface CsrfCheckInput {
headerToken?: string;
cookieToken?: string;
sessionId: string;
}
export function createCsrfToken(sessionId: string, secret: string): string {
if (!sessionId || !secret) throw new TypeError("sessionId and secret are required");
const nonce = randomBytes(18).toString("base64url");
const signature = createHmac("sha256", secret)
.update(sessionId + "." + nonce)
.digest("base64url");
return nonce + "." + signature;
}
export function verifyCsrfToken(input: CsrfCheckInput, secret: string): boolean {
const { headerToken, cookieToken, sessionId } = input;
if (!headerToken || !cookieToken || !sessionId) return false;
const header = Buffer.from(headerToken);
const cookie = Buffer.from(cookieToken);
if (header.length !== cookie.length || !timingSafeEqual(header, cookie)) return false;
const separator = headerToken.lastIndexOf(".");
if (separator <= 0) return false;
const nonce = headerToken.slice(0, separator);
const signature = headerToken.slice(separator + 1);
const expected = createHmac("sha256", secret)
.update(sessionId + "." + nonce)
.digest("base64url");
const provided = Buffer.from(signature);
const expectedBuffer = Buffer.from(expected);
return provided.length === expectedBuffer.length
&& timingSafeEqual(provided, expectedBuffer);
}
export function csrfCookie(token: string, secure = true): string {
const flags = ["Path=/", "SameSite=Lax", secure ? "Secure" : ""].filter(Boolean);
return "csrf_token=" + encodeURIComponent(token) + "; " + flags.join("; ");
}