# 代理 Cookie jar 如何保留你没打算保留的会话

HTTP 客户端会让 Cookie 看起来无害，因为浏览器早已让我们习惯了它们。自主代理会改变后果。带有 `Set-Cookie` 的响应，可能让下一次请求获得代理从未请求、从未展示的权限，而且即使原始凭据已经更换，代理仍可能保留这份权限。

把 Cookie jar 当作认证存储，而不是传输细节。如果 API 需要 Cookie 会话，就为这个 jar 指定名称、所有者、较短的生命周期和可观察的边界。如果不需要，就关闭它。我见过太多事故调查，开头都是有人坚持说请求没有凭据，但客户端却悄悄在 `Cookie` 请求头中发送了凭据。

## Set-Cookie 请求头可能变成第二份凭据

服务器会在响应中发送 `Set-Cookie`，拥有 jar 的客户端可能会在之后的 `Cookie` 请求头中发送这个值。如果这个值标识服务器端会话，从实际效果看，它就是一份凭据。它是在认证请求之后到达的，并不意味着它对下一次请求的授权能力更弱。

这会让团队陷入误区，因为他们关注的是自己有意提供的密钥：bearer token、basic-auth 密码或签名请求。他们会检查密钥的来源，以及它是否会到达代理。随后，HTTP 库在没有任何明确应用代码的情况下接受了会话 Cookie。即使原来的 Authorization 请求头已经消失，下一次请求仍可能成功。

真正重要的区别在于凭据注入和会话延续。凭据注入会把一个已知密钥附加到某个请求上。会话延续则允许服务器签发新的密钥，再让客户端在之后重放它。两者都能授权操作，但通常只有前者会出现在工具的调用参数中。

RFC 6265 把这种交换描述为状态管理：用户代理会存储来自 `Set-Cookie` 的 Cookie 信息，并在 `Cookie` 中返回适用的 Cookie。这种说法有意保持宽泛，因为 Web 浏览器需要这种能力。代理客户端不应仅仅因为 HTTP 协议允许保留状态，就继承浏览器保存状态的习惯。

Cookie 还会改变令牌轮换失败的含义。假设代理使用一个 token 调用 API，收到 `sid=...`，之后又失去了对该 token 的访问权限。如果 API 单独接受会话 Cookie，代理仍然有路径进入该账户。轮换修复了已知凭据，却没有终止已经签发的会话。这既是服务器端会话管理问题，也是客户端隔离问题。两边都要处理。

## 客户端决定是否存在隐藏状态

`Set-Cookie` 本身不会产生任何效果。客户端必须选择保留它。这种选择可能藏在许多地方：可复用的 HTTP 客户端、库内置的 Cookie 管理器、fetch 包装器、测试工具，或重定向实现中。

有些常见客户端只有在你提供 jar 后才会保留 Cookie。另一些客户端则会在代码附加标准 Cookie 处理器时保留 Cookie。这样一来，共享客户端可能意外共享同一个 jar。不要根据 API 文档或某次调用成功这一事实推断行为。检查客户端的创建方式，并运行探针测试。

一个干净的测试应使用你控制的服务器或无害的测试端点。第一次响应设置一个名称清晰的 Cookie，第二个请求报告它收到的请求头。测试需要分别回答两个问题：

1. 客户端收到 `Set-Cookie` 后是否保留了 Cookie？
2. 客户端是否在之后符合条件的请求中发送了该 Cookie？

使用 curl 时，明确指定 Cookie 文件可以让状态变得可见：

```
curl -i -c /tmp/agent-cookie-test.txt https://test.example/session/start
HTTP/1.1 200 OK
Set-Cookie: agent_probe=run-7f3; Path=/; Secure; HttpOnly

curl -i -b /tmp/agent-cookie-test.txt https://test.example/session/echo
HTTP/1.1 200 OK

{"received_cookie":"agent_probe=run-7f3"}
```

这个文件正是测试的重点。第一条命令不可能创建隐藏会话，除非有东西保留了它的响应。第二条命令也不可能重放会话，除非它拿到了这个文件。如果你的代理工具把这个文件的等价物放在一个没有名称、进程全局共享的位置，就创建了一个审查时无人能够解释的会话边界。

使用代理实际采用的完整运行路径重复探针测试，包括请求包装器和重定向设置。直接测试 curl 只能证明 curl 的行为，不能证明你的工具。记录 jar 是否从空状态开始、存放在哪里，以及什么操作会清除它。

## 调用之间的复用不同于运行之间的复用

同一个任务中的几次调用可能需要共享会话。在之后的任务中继续使用该会话，则是另一个决定。把这两种生命周期混在一起，小小的便利就会变成持久权限。

评估行为时要检查三个边界。首先，确认单个请求是否根本需要 Cookie。其次，确认同一个代理进程中的相关调用是否需要 Cookie。第三，确认全新的进程、恢复的任务、另一份凭据或另一个人是否应该继承这些 Cookie。每个“是”都需要明确理由。

随进程消失的内存 jar，比项目目录中的文件更容易控制。文件可能在崩溃、重试、工作区复制或任务执行人变更后继续存在。它也可能出现在支持归档或版本控制状态输出中。`HttpOnly` 无法保护写入它的客户端之外的 Cookie 文件，它只限制浏览器脚本访问 Cookie。

即使同一个模型收到后续提示，我也倾向于让每次代理运行使用全新的 jar。模型在对话上的连续性，不是保留 HTTP 权限的理由。如果后续操作确实需要该会话，就让操作人员批准在同一个命名边界下继续运行，而不是让上一次运行悄悄留下可用会话。

并发调用也需要单独决策。共享 jar 可能造成依赖顺序的行为：请求 A 收到 Cookie，请求 B 在片刻后开始，于是 B 获得了一个它从未建立的会话。这会让复现问题变得极其困难。除非 API 要求协调使用一个会话，而且任务明确拥有它，否则应为并发任务提供不同的 jar。

## Cookie 作用域并不等于人们想象的安全范围

Cookie 属性可以限制发送范围，但不会让会话变得无害。应把它们看作服务器发送给客户端的路由指令，然后再决定客户端是否应该遵守这些指令。

仅主机 Cookie 会返回给设置它的确切主机。带有 `Domain=example.com` 的 Cookie 可能会返回给符合条件的子域，例如 `api.example.com` 和 `admin.example.com`。RFC 6265 还规定，如果 Domain 值与来源主机不匹配，用户代理应拒绝它。但这并不能解决信任宽泛企业域名下所有子域这一常见错误。

`Path=/billing` 会将 Cookie 限制在路径与 Cookie 路径匹配的请求中。如果 Cookie 通过其他路由到达服务器，它并不能阻止另一个获准路径上的服务器接受同一个 Cookie，也不能代替授权检查。在设计讨论中不要把 Path 当作安全边界。它只是客户端发送 Cookie 的规则。

`Secure` 告诉客户端只通过安全传输发送 Cookie。`HttpOnly` 告诉浏览器不要通过脚本 API 暴露 Cookie。这两个属性都是良好实践，但都不能限制 Cookie 的保留、任务共享、重定向或代理使用 Cookie 的能力。`SameSite` 主要控制浏览器的站点上下文。非浏览器代理不应把它当作跨站行为安全的证明。

还有一个容易犯的错误：调试时记录原始请求头。只删除 `Authorization`、却保留 `Cookie` 的脱敏器，并没有完成认证信息脱敏。记录 Cookie 是否存在、Cookie 名称、声明的 Domain 和 Path、过期类型，以及必要时使用不可逆的关联标识符。不要记录 Cookie 值。

## 重定向会把 Cookie 测试变成目标测试

重定向不只是换了一个 URL。它可能改变下一个请求由哪台服务器接收、是否保留凭据，以及哪一个响应可以设置状态。要把重定向作为独立流程进行测试。

先设置一个在返回重定向后再设置 Cookie 的端点。然后分别测试每个目标：同一主机、获准的子域、相邻子域和无关主机。观察发出的 `Cookie` 和 `Authorization`。不同 HTTP 技术栈的选择可能不同，包装器也可能覆盖默认行为。

一个有用的测试装置会生成类似这样的简短追踪记录：

```
request 1  GET https://api.example.test/start
response 1  302 Location: https://api.example.test/next
            Set-Cookie: probe=A; Path=/; Secure
request 2  GET https://api.example.test/next
            Cookie: probe=A
response 2  200
```

然后只修改 Location 主机。如果 `api.example.test` 重定向到 `reports.example.test`，仅主机 Cookie 不应跟随。Domain 作用域的 Cookie 可能会跟随。测试应在运行前写明预期结果，因为让你感到意外的追踪记录才是测试重点，不应被当作麻烦掩盖过去。

如果 API 合约不要求跨源重定向，就拒绝它。如果必须跟随重定向，应比较原始来源和新来源，按照明确规则移除请求凭据，并让新的响应建立新的 Cookie 状态。不要使用宽泛的自动重定向策略，再假设 Cookie 作用域会替你解决问题。

## 会话测试必须跨越凭据和进程

能发现高代价问题的测试，不是“请求一，请求二”。它要跨越你的运行模型声称能够隔离的边界。

构建一个无害的端点，只有在收到指定的凭据标签后才签发 Cookie，并在每次授权请求中返回会话标签。然后执行以下序列：

1. 使用凭据 A 启动运行 A，并收到 `sid=A`。
2. 使用同一个 jar，但不带凭据 A，发起第二次调用。
3. 使用凭据 B 和空 jar 启动运行 B。
4. 不带凭据，并使用运行 A 中任何已持久化的 jar，启动运行 C。
5. 在测试服务器上撤销凭据 A 或使其会话失效，然后重试运行 A 的 jar。

预期输出取决于策略，而不是一个适用于所有情况的统一答案。基于 Cookie 的 API 可能有意允许运行 A 中的第二次调用。运行 B 不应看到 A 的状态。除非你明确批准持久化，否则运行 C 应失败。如果你的威胁模型要求令牌轮换移除现有访问权限，撤销后测试服务器应拒绝旧会话。

把预期写在测试旁边，不要把它藏在开发人员的记忆中。在测试注释中放一个简洁的表格就够了：

```
run A, same jar, no bearer header: allowed only if session continuation is intended
run B, fresh jar, credential B: must identify as B
run C, persisted jar, no credential: rejected
run A after session invalidation: rejected
```

这会揭示团队经常混淆的区别：撤销引导凭据与撤销已签发会话不是同一项操作。API 负责人必须使会话失效。代理工具负责人必须避免在获准生命周期之外保留会话。任何一方都不能假设另一方已经处理了这件事。

## 除非 API 需要，否则关闭隐式 Cookie jar

代理 HTTP 操作的合理默认设置是，不存储 Cookie，也不自动添加 `Cookie` 请求头。响应仍可能包含 `Set-Cookie`。如果审计设计允许，可以记录这件事，然后丢弃 Cookie。下一次调用应使用正常的请求凭据。

这项建议不受欢迎，因为许多邻近 Web 的 API 只有在登录端点之后由客户端保留 Cookie 才能工作。人们会使用共享 jar，让演示和集成测试通过。当文档化 API 支持 bearer token 或其他限定在请求范围内的机制时，这是错误的解决方式。你是在保留不可见权限，以弥补本不该选择的集成路径。

如果 API 确实需要 Cookie，就把 jar 设为一种明确的能力。调用方应在运行开始时选择一个命名且为空的 jar，工具应报告响应何时创建或替换 Cookie，并在运行结束时让 jar 消失。不要让某个端点仅仅通过返回一个请求头，就让所有代理进入持久会话。

一份最小策略可以简单到便于审查：

```
cookie mode: disabled by default
allowed mode: ephemeral per agent run
persistence: prohibited
sharing: prohibited between agent processes
redirects: same-origin only unless the action definition permits another origin
logging: record cookie names and scope, never values
```

这项策略有意没有规则引擎那么复杂。聪明的 Cookie 策略会不断增加例外，直到没人能说清哪个操作携带了哪些状态。一小组明确选项，才能给审查人员真正的答案。

## 审计状态变化，而不只是审计请求

只列出 URL 和状态码的审计记录，会漏掉最有用的事件：某个响应改变了之后调用能够执行的操作。应把 Cookie 状态变化作为一等事件记录，同时不要保留会话密钥本身。

对于每次 HTTP 操作，记录都应能回答：请求是否发送了 Cookie，响应是否设置或清除了 Cookie，哪个 jar 收到了 Cookie，该 jar 是否属于本次运行，以及是否发生了重定向？Cookie 名称和属性通常足以完成诊断。只有在你确实拥有保护和过期管理它们的安全设计时，才保存 Cookie 值，而大多数代理工具并不需要这样做。

当代理使用 Sallyport 执行 HTTP 操作时，vault 可以在操作执行期间将配置好的 API 凭据留在代理之外。这种隔离只有在 HTTP 客户端同样谨慎地处理返回的会话状态时才有用，而不是悄悄把它变成另一条凭据路径。

审计记录还需要一个人类可读的运行边界。如果操作人员撤销某次代理运行，他们应当知道这项操作是否只能阻止之后通过配置凭据发起的调用，还是也会清除与该运行关联的会话状态。如果不会清除，就应明确说明，并让后续进程无法访问剩余状态。

我会加入的第一个测试有意保持无聊：一个端点发送 `Set-Cookie`，下一个端点确认它是否到达，然后由新的代理进程重复调用。在加入重试、重定向、浏览器兼容性或持久缓存前，先运行这个测试。如果从追踪记录中看不出明确答案，说明客户端拥有的权限超过了其界面承认的范围。

## API 作者可以在无需猜测的情况下让代理更安全

API 作者应说明是否需要 Cookie、什么操作会创建 Cookie、预期生命周期是什么，以及客户端如何使 Cookie 失效。当登录可能签发一个生命周期超过获取它所用凭据的会话时，仅仅写一句“登录后使用此端点”是不够的。

尽可能提供限定在请求范围内的替代方案。Bearer 认证、签名请求或作用域狭窄的操作令牌，通常更容易审计客户端行为，因为每个调用都会公开携带自己的权限。这并不意味着这些机制自动安全，但它们避免了隐藏在客户端内存中的额外重放通道。

如果签发会话 Cookie，就要让会话失效机制真正有效，并对它进行测试。只轮换令牌而不使会话失效，会让操作人员产生已经完成工作的错误感觉。如果同时接受 bearer token 和会话 Cookie，就要定义两者不一致时哪一个优先，并在诊断输出中公开这一选择。

除非服务确实依赖浏览器行为，否则不要要求代理构建者模仿浏览器。代理会重复发起无人值守的调用，而且经常跨越不同任务运行。浏览器长期保留的便利状态，不适合成为这种环境的默认设置。

## 安全默认值是使用没有记住会话的新客户端

Cookie 支持本身没有问题，没有所有者的状态才是问题。一个短期存在、明确选择的 jar，可能是完成多次调用 API 任务的正确方式。一个因为有人复用了 HTTP 客户端而出现的 jar，则是在等待合适的重试、重定向或凭据轮换来暴露问题。

让工具界面和审计记录都清楚显示会话边界，然后用跨运行测试证明它。请求追踪应让审查人员能够指出代理拥有的每条凭据路径，包括服务器试图交还给它的那些路径。
