# AI 代理的 HTTP 连接池凭据隔离

复用 HTTP 连接并不会自动造成凭据泄漏。把它当成无害行为，团队就容易错过真正的问题：客户端对象累积了 Cookie、挑战响应、重定向标头、代理身份或 TLS 身份，之后另一个凭据发起的操作复用了这些状态。

HTTP 连接池凭据隔离，指的是明确哪些状态可以跨请求传递，并让其余状态无法共享。对于可以代表多个账户执行操作的任务执行器或代理来说，仅以目标主机作为边界通常太宽泛。一个偶尔发送错误账户 Cookie 的快速客户端，比慢一些但不会串用账户的客户端更糟，因为这种错误看起来像一次正常且成功的请求。

## 套接字是传输工具，不属于账户

TCP 连接会承载某个源站的数据。它不会为应用提供可信的账户边界。HTTP/1.1 会按顺序复用连接，HTTP/2 可以同时运行多个流，但这两种协议都没有规定连接上的每个请求必须属于同一个用户或服务账户。

这个区别很重要，因为客户端库经常把互不相关的功能放在一个好用的接口后面，称为 session、agent、client 或 transport。开发者于是按主机创建一个对象，并在每次调用前添加 bearer token。Bearer 标头可能每次都正确，但 Cookie jar、摘要挑战缓存或代理上下文可能悄悄属于上一个使用该对象的账户。

RFC 9110 将认证视为请求和保护空间的行为。客户端会针对特定的源站、方案和 realm 响应挑战。它没有说开放的连接拥有某个人或某个服务账户。如果客户端做出了这种所有权假设，假设来自你的代码或所用的库，而不是 HTTP。

连接复用仍然值得保留。它可以避免重复握手，减少端口频繁变动，也能帮助远程服务应对负载。正确的规则更具体：只有当请求的连接级状态和客户端管理状态确实兼容时，才在这些请求之间复用连接。

值得分开的状态可以分成两组：

- 请求状态包括 Authorization、Cookie、账户标头、请求体和幂等性值。调用方必须为每次操作重新构建这些内容。
- 连接和客户端状态包括连接池条目、代理会话、客户端证书选择、重定向行为、Cookie、挑战缓存和协议设置。客户端必须有意识地限定或禁用其中每一项。

如果 bearer 凭据只放在 Authorization 标头中使用，且库会按请求发送标头、不保存任何账户状态，那么它可以与另一个 bearer 凭据共享 TCP 连接。这只是有限条件下的结论，不是全面许可。只要服务还设置会话 Cookie、重定向到另一个主机，或要求客户端证书，就需要重新审视同一个连接池设计。

## Cookie 即使名称看起来无害，也属于账户状态

Cookie jar 是账户意外串用最常见的来源。团队看到的是 bearer token API，便以为 Cookie 无关紧要，直到负载均衡器、交互式登录端点或旧服务在响应中加入 `Set-Cookie`。通用 HTTP 客户端通常会自动保存它，除非你明确禁止。

RFC 6265 定义了用户代理如何根据域名、路径、安全属性及相关规则选择 Cookie。它的存储模型没有「接收该 Cookie 的凭据记录」这一字段。因此，两个 bearer 凭据访问同一个主机和路径时，可能符合相同的已存储 Cookie。协议完全按规则运行，但应用却跨越了账户边界。

下面是一项测试服务的请求轨迹。账户 alpha 使用 bearer token，并收到一个亲和性 Cookie：

```http
GET /v1/whoami HTTP/1.1
Host: api.example.test
Authorization: Bearer alpha-token

HTTP/1.1 200 OK
Set-Cookie: route=alpha-node; Path=/; Secure; HttpOnly
Content-Type: application/json

{ "account":"alpha" }
```

下一个操作选择 beta。如果共享的 jar 发送了已存储的 Cookie，请求就会带上两个所有权信号：

```http
GET /v1/whoami HTTP/1.1
Host: api.example.test
Authorization: Bearer beta-token
Cookie: route=alpha-node
```

最好的结果是服务拒绝这种不匹配。考虑不周的服务可能会把 beta 的请求路由到 alpha 的粘性会话，通过错误的服务器端上下文处理它，或接受恰好生效的那个标头。客户端不能指望远程服务来挽救混杂的请求。

对于机器对机器的 API，我的默认建议很直接：禁用环境 Cookie 存储。如果 API 确实需要 Cookie，就为一条凭据记录和一个预期的服务上下文创建专用 Cookie jar。不要仅仅因为主机字符串相同就共享它。

还要在注入凭据前检查请求输入。代理、插件或调用方不能自行提供一个 `Cookie` 标头，并让它在带凭据的调用中继续存在。应先删除环境认证标头和 Cookie，再只添加操作定义允许的标头。否则，你只是围绕一扇调用方可以绕过的门建立了隔离。

## 认证挑战需要对应当前调用方

401 响应不只是一个错误代码。它可能邀请客户端在选择凭据后重试，而选择逻辑通常位于创建原始请求的代码之外。Basic 和 Digest 认证处理器尤其容易出现这种模式，但自定义中间件也可能在 bearer token 上犯同样的错误。

错误实现会在共享客户端上保存一个可变的 `currentCredential`。请求 A 收到挑战，处理器加载 alpha 的机密，客户端随后重试。请求 B 在重试完成前开始，并将 `currentCredential` 改成 beta。现在挑战处理器出现了竞态，而测试套件却发现不了，因为测试通常一次只运行一个请求。

不要通过给全局凭据字段加锁来修复。锁会串行化工作，却仍然让错误的对象负责身份。应将凭据记录绑定到请求上下文，在每次重试中携带它；如果重试缺少上下文，就拒绝重试。

Digest 认证尤其需要警惕。它涉及 nonce、realm、计数器和计算得到的响应。这些值与受到挑战的保护空间绑定，而不是与一个通用共享客户端绑定。Basic 认证在线路上更简单，但如果缓存会自动把它添加到后续请求，同样需要限定凭据范围。

使用一个测试端点，为每个账户返回不同的 realm 或挑战。然后运行并发请求，断言每次重试都使用附加到自身操作的凭据记录。只检查最终是否返回 200 的测试远远不够。应在服务端捕获请求身份，并在 alpha 的挑战产生 beta 的授权尝试时让测试失败。

不要把 403 当成挑战。服务器会用 403 表示许多不同的授权决定，客户端不应把它当成寻找其他凭据的许可。在不同凭据之间自动回退，会把访问失败变成账户探测，既不安全，也难以审计。

## HTTP/2 更容易掩盖所有权错误

HTTP/2 允许多个流共享一个 TLS 连接。这很高效，但也消除了一个请求排在另一个请求之后的旧有直观迹象。客户端可以同时发送 alpha 和 beta 的操作，只有当库从底层到顶层都把它们建模为独立请求时，它们的标头才会保持分离。

RFC 9113 禁止在 HTTP/2 请求中使用 `Connection` 等 HTTP/1.1 特有的连接级标头。这个规则并不会让 Cookie 或认证自动限定到某个账户。它只阻止一类跳转行为通过普通请求标头表达。客户端仍然负责 Cookie 选择、重试选择、重定向和所有共享中间件。

HTTP/2 连接合并又带来了一个问题。当证书和名称检查允许时，一些客户端可以用一个安全连接访问多个源站。连接合并本身不会自动重放 Authorization 标头，但这意味着根据随意的主机比较建立的池身份，可能无法描述客户端实际选择的连接。应将源站授权决策与传输优化分开，并测试你部署的库的实际行为。

标头压缩也常常让人担心错地方。HPACK 会在 HTTP/2 连接内压缩标头，QPACK 则在 HTTP/3 中执行类似工作。符合规范的客户端不会把上一个请求的 Authorization 值解码到后续请求中。真正的风险是客户端代码直接复用了状态，以及在少数威胁模型下可能存在的元数据问题。如果库提供了控制项，可以将敏感标头标记为永不索引，但不要把它当成请求隔离的替代品。

HTTP/3 将传输从 TCP 改为 QUIC，但不会改变所有权规则。如果连接池允许 Cookie jar 或凭据缓存自由地在操作之间流动，那么换成 QUIC 后仍然是错误的。

客户端证书则不同。TLS 客户端证书会在建立连接时选择，因此它确实绑定到连接。绝不要让不同客户端证书身份通过同一个连接或池条目进行多路复用。应将证书记录纳入池身份，或为每个证书使用独立的客户端实例。对于在转发请求前先认证连接的代理，也要采取同样的谨慎做法。

## 在代码中明确连接池身份

连接池身份应包含所有会改变新连接安全含义的值。对于禁用 Cookie 且没有代理身份的纯 bearer API，身份可能只需要包含源站和凭据记录标识符。对于客户端证书、经过代理认证的路径或特殊 TLS 配置，还应加入相应标识符。

不要使用机密文本作为映射标识符。这样会在内存中制造不必要的机密副本和日志，增加轮换难度，也容易让人调试时打印整个映射。应使用不透明的凭据记录 ID，并由保险库或操作注册表控制它的生命周期。

下面这段 TypeScript 展示了基本结构。它使用 Undici 的 `Pool`，但这个边界适用于任何客户端库。`credentialId` 是标识符，不是 bearer 值。

```ts
import { Pool } from "undici";

type Boundary = {
  origin: string;
  credentialId: string;
  proxyId?: string;
  clientCertificateId?: string;
};

const pools = new Map<string, Pool>();

function poolId(boundary: Boundary): string {
  return [
    boundary.origin,
    boundary.credentialId,
    boundary.proxyId ?? "direct",
    boundary.clientCertificateId ?? "none"
  ].join("\u001f");
}

function poolFor(boundary: Boundary): Pool {
  const id = poolId(boundary);
  let pool = pools.get(id);
  if (!pool) {
    pool = new Pool(boundary.origin);
    pools.set(id, pool);
  }
  return pool;
}

function headersFor(token: string, input: HeadersInit = {}): Headers {
  const headers = new Headers(input);
  headers.delete("authorization");
  headers.delete("cookie");
  headers.delete("proxy-authorization");
  headers.set("authorization", `Bearer ${token}`);
  return headers;
}
```

这段代码可以防止调用方提供的 Authorization 或 Cookie 标头和注入的凭据一起发送。它没有实现 Cookie jar、重定向、代理分发或证书配置。这个省略是有意的：每一项都需要明确决策，而不是接受意外的默认行为。

如果每个凭据都访问同一个匿名公共端点，单独建立连接池可能没有必要。如果端点能看到不同账户，就应先使用独立的池条目，除非你有具体理由和证据证明可以共享。相比账户串用，一个连接池的成本很低。

轮换需要自己的规则。凭据记录发生变化时，应停止将新操作分配给旧池。如果操作语义允许，可以让已经发送的请求继续使用原记录完成，然后关闭该连接池。不要把正在进行的重试转到替换后的凭据记录上。轮换改变的是权限，不会修复已经开始的工作的含义。

一种常见建议是到处禁用 keep-alive。它减少了共享传输对象的数量，所以事故后看起来更安全。但它也掩盖了 Cookie、重定向代码和挑战缓存是否正确限定范围的问题，还会让每次请求都产生新的握手负载。保留连接池，明确其所有权，并测试困难路径。

## 重定向可能把干净的请求送到错误位置

重定向处理其实是藏在第一个客户端里的第二个客户端。库收到 301、302、303、307 或 308 响应后，会构建另一个请求，并决定哪些标头可以保留。如果这个决定发生在凭据边界代码之后，就可能把账户标头或 Cookie 带到不应到达的目标。

对于注入凭据的操作，最初应禁用自动重定向，或改为手动处理。跟随重定向前，先检查目标源站。只有在操作定义明确预期重定向、目标位于获准的源站集合内，并且客户端会从原始凭据上下文重新构建标头，而不是复制旧标头集合时，才允许重定向。

状态码很重要。在普通浏览器行为中，303 可以将请求改为 GET，而 307 和 308 会保留方法和请求体。把它们一视同仁的重试客户端，可能会在另一个端点重复执行带凭据的写操作。即使没有机密跨越源站，这仍然是操作完整性问题。

每次跨源站重定向都要删除 Authorization 和 Cookie 标头。很多客户端已经默认这么做，但默认行为不能代替测试。应验证你所使用的版本和配置。同源重定向也需要路径白名单，尤其是当凭据授予的权限超过原始端点应有的范围时。

代理认证需要同样的处理。`Proxy-Authorization` 属于选定的代理路径，而不是源站服务。绝不要把它放进通用默认标头映射。如果两个操作通过不同的已认证代理访问同一个 API，就应分开它们的池条目，并在操作记录中明确显示代理选择。

## 失败的隔离测试有明显特征

大多数团队会测试 alpha 能调用 API，beta 也能调用 API。这能证明凭据有效，却不能证明同一个长期存在的客户端从 alpha 切换到 beta 时不会带上上一个账户留下的状态。

建立一个小型测试服务器，设置两个账户，并让它报告收到的内容。它应返回从 Authorization 推导出的账户，回显收到的 Cookie 标头，设置账户专属 Cookie，并提供重定向端点。当 Cookie 的账户标签与 bearer 账户标签不一致时，服务应拒绝请求。这样能让错误直接显现，而不是让亲和性层把它隐藏起来。

使用生产环境中的确切客户端配置运行以下步骤：

1. 通过一个全新的连接池发送 alpha 请求。确认响应设置了 `route=alpha`。
2. 通过生产环境使用的池查找逻辑发送 beta 请求。断言服务端看到的是 beta 授权，且 Cookie 标头为空，除非 beta 使用只包含 beta Cookie 的独立 jar。
3. 通过 HTTP/2 并发启动 alpha 和 beta 请求。让每个端点在响应前暂停，使两个流重叠，然后分别断言它们的身份和 Cookie。
4. 为每个账户返回 401 挑战，确认重试使用原始凭据记录。
5. 重定向到另一个源站，确认后续请求没有 Authorization 或 Cookie 标头。

断言不应只记录状态码。还要捕获选定的凭据记录 ID、池 ID、源站、协议版本、重定向目标、Cookie 是否存在以及响应账户。不要记录凭据本身。测试失败时，这些字段可以帮助确定泄漏来自池查找、环境标头、jar 还是重试中间件。

也要测试凭据轮换。让一个请求在服务端等待，替换凭据记录，然后发出新请求。新操作必须使用新的池身份。确定并记录等待中的操作是继续使用原权限完成，还是被取消。两种做法都有合理之处，只有在执行过程中悄悄改变权限不可接受。

通过你支持的所有代理配置运行测试。代理状态和源站状态往往位于不同的库层中，因此这里最容易让看似放心的单元测试变成真正有用的集成测试。

## 代理操作需要更小的权限范围

自主编程代理应请求这样的操作：「使用凭据记录 billing-read 调用这个获批的 API」。它不应拿到 token，然后构建一个宽泛且长期存在的客户端，在任务改变后仍然保留会话状态。操作执行器可以在发送任何数据之前绑定获批的目标、方法、凭据记录和客户端边界。

Sallyport 将 API 和 SSH 凭据保存在加密保险库中，并自行执行操作，让代理接收结果而不是凭据。这种分离只有在 HTTP 路径也将每条凭据记录视为独立权限上下文时才有用。

审批不能代替客户端隔离。操作员可以批准一个合法的 beta 操作，但粗心的共享 Cookie jar 仍可能把它变成混合了 alpha 和 beta 的请求。这样一来，审批记录记录的是操作员想允许的操作，而实际请求却不同。应把边界放在审批界面下方，因为标头、重试和传输选择正是在那里发生的。

对于操作网关，应在审计事件中记录凭据记录 ID 和池身份，但绝不能记录机密。如果调用方报告账户不一致，你需要确定执行器是否选对了凭据，以及是否只附加了该记录拥有的状态。防篡改活动记录有助于调查这个问题，但无法让含糊的客户端设计变得安全。

第一个实际改动很简单：找出所有共享的 HTTP 客户端，并列出它们除了开放连接之外还记住了什么。如果答案包括 Cookie、挑战响应、重定向、客户端证书或代理身份，就应在下一次令牌轮换或并发代理运行让问题变得昂贵之前，为每一项指定明确的所有者。
