存储范围不是身份系统。Cache-Control: private 要求遵守协议的共享缓存不存储这条响应,却不会教会浏览器识别正文属于哪个应用账户。当一个人退出登录,另一个账户在同一浏览器中登录,然后访问相同网址时,这个区别就会变成实际的数据隔离问题,不能只凭响应头里出现 private 就判定安全。

有资格被缓存不代表能共享

不带字段名参数的 private 允许单用户缓存保存响应,但仍受其他缓存条件约束。浏览器是常见实现,并不是唯一实现。public 则明确允许某些原本可能受到限制的响应进入缓存,例如满足条件的带授权请求响应。已经符合缓存条件的普通响应不一定需要 public;出现 public 也不意味着所有缓存都必须保存它,存储许可和实际是否存储是不同事实。

不带参数的 no-cache 与 no-store 回答的是另一个问题。前者允许存储,但每次复用前必须成功验证;后者禁止私有和共享 HTTP 缓存存储及随后复用该响应。它们都不是加密、访问控制检查,也不承诺清除应用日志或用户之前保存的副本。本文讨论不带字段名参数的形式,不把实现支持较弱的按字段限定形式混进实验。

新鲜度还需要单独判断。错误的个人响应如果按共享键保存,即使只给五秒钟有效期,也可能有五秒钟把数据交给另一个人。缩短 max-age 不能修复身份边界。反过来,s-maxage 只是控制共享缓存的新鲜度,并不是 private 的另一种写法。先决定响应可在哪一层保存,再讨论可直接复用多久,才能避免用性能参数代替安全条件。

坏掉的样子

HTTP 的通用规则不会因为请求里有 Cookie 就自动禁止共享缓存。Authorization 确实有专门的共享缓存限制,但 public 等指令能够明确允许存储。为了提高命中率而添加 public,可能恰好撤掉应用之前依赖的限制。修改指令前应检查实际响应体和认证路径,不能只查看页面看起来是不是一份普通文档。

Vary 细化的是请求匹配条件,不授予存储许可。增加 Vary: Cookie 不会让 private 响应变得可以进入共享缓存。在私有缓存内,它可以区分不同 Cookie 字段,但这仍然只是字段匹配,不是授权判断。假设令牌文本没有变化,而账户权限已经被撤销,仅凭请求字段仍匹配,不能证明当前用户继续有权读取旧内容。

敏感账户数据需要明确的响应策略和账户切换测试,至少覆盖退出登录、同一浏览器配置中切换账户、权限发生变化。诊断记录也不应该包含真实凭据。只在源站检查到正确的 Cache-Control,还不能证明沿途每一个代理都遵守了它,部署后的响应正文与源站访问记录仍是必要证据。

快速验证

准备 Node.js 与 npm;本次实际使用 Node 26.0.0 和 http-cache-semantics 4.2.0。将固定版本安装到临时目录,然后使用 NODE_PATH="$cache_lab/node_modules" node 执行下面的 JavaScript。安装需要网络,实验本身不会发请求;其中的 Cookie 和 Authorization 值均为虚构文本,不对应任何真实账户或令牌。

cache_lab=$(mktemp -d)
npm install --prefix "$cache_lab" --ignore-scripts --no-audit --no-fund --package-lock=false http-cache-semantics@4.2.0
const assert = require("node:assert/strict");
const CachePolicy = require("http-cache-semantics");

const request = {
  url: "https://example.test/account",
  method: "GET",
  headers: { cookie: "account=alpha" },
};
const cases = [
  ["private, max-age=300", [false, false], [true, true]],
  ["public, max-age=300", [true, true], [true, true]],
  ["no-cache, max-age=300", [true, false], [true, false]],
  ["no-store, max-age=300", [false, false], [false, false]],
];
for (const [directive, sharedResult, privateResult] of cases) {
  for (const shared of [true, false]) {
    const policy = new CachePolicy(request, {
      status: 200,
      headers: { "cache-control": directive },
    }, { shared });
    const result = [policy.storable(),
      policy.storable() && policy.satisfiesWithoutRevalidation(request)];
    assert.deepEqual(result, shared ? sharedResult : privateResult);
    console.log(shared ? "shared" : "private", directive, result);
  }
}

const authorized = { ...request, headers: { authorization: "Bearer example" } };
for (const [directive, expected] of [
  ["max-age=300", false], ["public, max-age=300", true],
]) {
  const policy = new CachePolicy(authorized, {
    headers: { "cache-control": directive },
  }, { shared: true });
  assert.equal(policy.storable(), expected);
  console.log("authorized shared:", directive, expected);
}

const otherAccount = { ...request, headers: { cookie: "account=beta" } };
for (const vary of [false, true]) {
  const headers = { "cache-control": "private, max-age=300" };
  if (vary) headers.vary = "Cookie";
  const policy = new CachePolicy(request, { headers }, { shared: false });
  assert.equal(policy.satisfiesWithoutRevalidation(otherAccount), !vary);
  console.log("different cookie, Vary enabled:", vary, "reuse:", !vary);
}

每组结果依次表示“有资格存储”和“无需验证即可复用”。private 在共享模式下得到两项 false,在私有模式下得到两项 true;public 在两种模式中都得到两项 true。no-cache 可以存储但不能直接复用,no-store 则两项都不允许。代码有意先检查存储资格,调用方不能先把禁止存储的条目插入缓存,再希望后续查找逻辑补救这个错误。

Authorization 两项检查说明,明确加入 public 后,共享存储资格从 false 变成 true,但这不证明任何真实个人响应适合公开共享。最后两项只改变虚构 Cookie:没有 Vary 时,私有缓存中的旧条目仍能匹配;启用 Vary 后则不能匹配。这是 private 本身不会隔离应用账户的具体反例,而不是建议仅靠一个 Vary 字段完成安全设计。

本实验验证策略库,不代表已经验证浏览器、CDN 或退出登录实现。生产验收仍应使用两个不同账户,观察真实路径上的源站请求,并断言最终正文属于正确账户。先确定这项验收,再调整新鲜度或统计命中率,才能知道性能改善有没有越过数据边界。

延伸阅读

来源