← 中文资源

简体中文版本

RFC 6265 Cookie 序列化与解析

按 RFC 6265 生成带 Max-Age、Expires、Path、Domain、Secure、HttpOnly 与 SameSite 的 Cookie,并解析请求头。

阅读英文原文

功能说明

该示例覆盖浏览器 Cookie 的写入与读取。serializeCookie 校验名称 token 与值类型,对值做 URL 编码,并按规范顺序拼接属性;parseCookieHeader 解析请求中的 Cookie 头,重复名称保留第一次出现的值。适用于服务端渲染时设置会话 Cookie、在测试中检查 Set-Cookie,或实现轻量的请求上下文读取。

关键代码

名称必须匹配 RFC 6265 的 token 字符集,值统一用 encodeURIComponent 编码并限制长度,避免响应头膨胀。Max-Age 必须是整数秒,Expires 必须提供合法 Date 并转换为 UTC 字符串。Path 要求以斜杠开头,Domain 拒绝分号与空白。SameSite 支持 lax、strict、none 三种写法,其中 none 强制要求 Secure,防止浏览器拒绝写入。属性输出顺序为 Max-Age、Expires、Path、Domain、SameSite、Secure、HttpOnly。

使用注意

该函数只负责序列化,不判断当前页面是否 HTTPS,也不会自动添加 Secure。HttpOnly 的 Cookie 无法被 JavaScript 读取,因此不要在客户端依赖它保存需要读取的数据。解析器不处理带引号的旧式值,也不会验证 Cookie 名是否合法;它只用于读取请求头并做容错。不要在 Cookie 中存放明文敏感信息,编码不等于加密。

验证方式

断言序列化结果包含正确的分号分隔顺序与 SameSite=Lax;测试 SameSite=None 未加 Secure 时抛 TypeError;用非法名称、负数 Max-Age、无效 Date 与不带斜杠的 Path 验证参数错误;解析含重复键、百分号编码与损坏编码的 Cookie 头,确认首值优先且坏编码回退为原文。

代码示例

const TOKEN = /^[!#$%&'*+\-.^_\x60|~0-9A-Za-z]+$/;

export function serializeCookie(name, value, options = {}) {
  if (typeof name !== "string" || !TOKEN.test(name)) {
    throw new TypeError("invalid cookie name");
  }
  if (typeof value !== "string") throw new TypeError("cookie value must be a string");
  const encoded = encodeURIComponent(value);
  if (encoded.length > 4096) throw new RangeError("cookie value is too large");
  const parts = [name + "=" + encoded];
  const {
    maxAge,
    expires,
    path,
    domain,
    secure = false,
    httpOnly = false,
    sameSite,
  } = options;

  if (maxAge !== undefined) {
    if (!Number.isInteger(maxAge)) throw new TypeError("maxAge must be an integer");
    parts.push("Max-Age=" + maxAge);
  }
  if (expires !== undefined) {
    if (!(expires instanceof Date) || Number.isNaN(expires.getTime())) {
      throw new TypeError("expires must be a valid Date");
    }
    parts.push("Expires=" + expires.toUTCString());
  }
  if (path !== undefined) {
    if (typeof path !== "string" || !path.startsWith("/")) {
      throw new TypeError("path must start with /");
    }
    parts.push("Path=" + path);
  }
  if (domain !== undefined) {
    if (typeof domain !== "string" || domain.length === 0 || /[;\s]/.test(domain)) {
      throw new TypeError("invalid cookie domain");
    }
    parts.push("Domain=" + domain);
  }
  if (sameSite !== undefined) {
    const normalized = String(sameSite).toLowerCase();
    if (!["lax", "strict", "none"].includes(normalized)) {
      throw new TypeError("sameSite must be lax, strict or none");
    }
    if (normalized === "none" && !secure) {
      throw new TypeError("SameSite=None requires Secure");
    }
    parts.push("SameSite=" + normalized[0].toUpperCase() + normalized.slice(1));
  }
  if (secure) parts.push("Secure");
  if (httpOnly) parts.push("HttpOnly");
  return parts.join("; ");
}

export function parseCookieHeader(header) {
  const cookies = new Map();
  if (typeof header !== "string" || header.trim() === "") return cookies;
  for (const part of header.split(";")) {
    const equals = part.indexOf("=");
    if (equals <= 0) continue;
    const name = part.slice(0, equals).trim();
    const raw = part.slice(equals + 1).trim();
    if (!name || cookies.has(name)) continue;
    try {
      cookies.set(name, decodeURIComponent(raw));
    } catch {
      cookies.set(name, raw);
    }
  }
  return cookies;
}