# HTTP 请求注入可以从换行符开始吗？

凭据注入器可以让 API 密钥远离代理上下文，却仍然把密钥发送到错误的请求中。问题出在网关把代理输入当作无害文本，然后在凭据层完成工作后，将这些输入粘贴进 HTTP 语法。

我见过一些团队在保管库存储、审批提示和审计日志上投入大量精力，却在代理和网络之间留下一个字符串格式化器。这个格式化器也属于安全边界的一部分。如果它允许目标、自定义请求头名称或代理提供的请求头值中出现回车或换行，就可能让数据创建请求结构。

修复方案有意保持简单：在渲染请求前拒绝回车和换行，解析结构化输入，不要用字符串拼接来组合请求，并测试真正离开进程的字节。不要试图通过去除首尾空白或替换来清理这类输入。拒绝请求才是诚实的处理方式。修复后的请求可能已经变成了另一个请求。

## HTTP 请求注入可以从换行符开始吗？

可以。在 HTTP/1.1 中，换行符用于分隔消息的不同部分。如果代码把不可信文本放入请求行或请求头行，却没有强制执行这一边界，攻击者就可以使用 CRLF，也就是回车后跟换行，结束当前行并开始另一行。

一个简化的易受攻击渲染器看起来往往没有问题：

```text
GET {path} HTTP/1.1\r\n
Host: {host}\r\n
Authorization: Bearer {vault_token}\r\n
{custom_name}: {agent_value}\r\n
\r\n
```

假设代理提供了以下值：

```text
blue\r\nX-Forwarded-Host: internal.example
```

渲染后的字节现在包含了一个额外的请求头。凭据没有泄露到代理的提示词中，但代理仍然以设计者没有预料到的方式影响了已认证请求。

当网关自行构造第一行时，或某一层解析 URL、随后另一层又通过文本格式化组合路径、主机或代理目标时，目标字符串中也会出现同类问题。具体结果取决于 HTTP 库和中间层的行为。这种不确定性不能成为防御理由。不同组件对消息边界产生分歧，正是小小的验证遗漏演变成安全事件的方式。

RFC 9110 将 HTTP 字段行定义为由字段名、冒号和字段值组成的语法。它也将换行视为消息成帧的一部分，而不是字段内部的普通内容。这个区别比笼统地提醒人们“转义用户输入”更重要。对于这个问题，字段中不存在有用的换行转义形式。拒绝它。

## 保管库保护凭据，不保护请求结构

凭据注入和请求注入解决的是两个不同的问题。前者让机密远离代理，后者确保接收机密的请求具有人工批准的结构。

设想一个工具允许代理使用保管库中的 bearer token 调用计费 API，同时接受可选的自定义请求头。开发者可能会认为这个请求头风险较低，因为令牌从未出现在工具参数中。这种推理忽略了相邻内容的影响。注入的一行可以添加转发请求头、修改内容长度、复制应用请求头，或污染下游使用的审计标签。凭据仍然保密，但操作已经变得不安全。

目标同样值得怀疑。看起来像数据的主机名可能会变成 authority 语法。路径可能会变成请求目标。代理选择器可以改变客户端连接的位置。如果代理能够影响这些值中的任何一个，网关就必须在附加凭据之前决定哪些形式有效。

因此，允许使用的主机名列表虽然有用，却仍然不够。它可以限制解析后的 URL 指向哪里，但不能证明每个组件收到的是同一个解析后的 URL，也不能阻止用户信息中的换行、请求头覆盖，或在允许列表检查后追加的字符串。边界必须拒绝控制字符，并让结构化值一直保持到 HTTP 客户端。

## 在创建请求对象前拒绝 CR 和 LF

最安全的规则可以用一句话说清楚：代理控制的目标字段、自定义请求头名称和自定义请求头值，在解码后都不得包含 `\r` 或 `\n`。

在构建 URL、创建请求头、选择客户端选项，或写入之后可能用于请求重试的日志之前应用这条规则。检查原始字符串，以捕获字面控制字符。按照输入契约完成一次解码，然后再次检查解码后的字符串。不要反复解码直到内容不再变化。反复解码会让清晰的契约变成猜测，也可能产生意外结果。

拒绝修复输入的紧凑验证函数应当类似这样：

```text
validateRequestText(field, value):
  if value contains "\r" or "\n":
    fail(field + " contains a line break")
  return value
```

在实际代码中，要验证语言真正的字符串值，而不是打印出来的表示。JSON 载荷可能包含 `"\n"`，JSON 解析后它会变成一个换行符。检查两个可见字符反斜杠和 n，会漏掉这个危险值。

不要调用 `trim()` 后继续执行。大多数实现中的 trim 只会移除两端的内容，因此中间的换行仍然存在。把换行替换为空格则会以另一种方式造成更糟的问题：它改变了请求的操作，却没有给操作人员留下清晰记录，无法知道代理曾请求使用被禁止的语法。应将输入视为无效，并在网络 I/O 之前停止。

拒绝响应应指出具体字段，但不要回显敏感内容。`custom header value contains a line break` 已经足够让调用者了解问题。回显完整值可能会把凭据或私人数据写入终端记录。

## 请求头名称需要比请求头值更严格的契约

请求头值可以合理地包含空格和许多可见字符，但请求头名称不应如此。允许代理选择任意名称，会给大多数操作网关带来不必要的歧义。

采用保守的名称规则：只允许 ASCII 字母、数字和连字符，并设置合理的长度限制。除非有明确记录的理由支持某个特定扩展，否则拒绝冒号、空白字符、控制字符和非 ASCII 字节。目标不是模仿互联网上每个宽松的解析器，而是生成一个明确无歧义的请求。

即使表面上的值很普通，下面的输入也必须失败：

```json
{
  "name": "X-Trace\r\nAuthorization",
  "value": "debug"
}
```

下面这个也必须失败：

```json
{
  "name": "X-Trace: injected",
  "value": "debug"
}
```

冒号属于渲染器，不属于提供的名称。如果网关接受它，一个解析器可能将其视为名称，而另一个解析器可能将其视为完整的字段行。带前导空白的请求头名称也会引发类似分歧。

除了 CRLF，请求头值还需要单独的策略。确定网关是否接受重复请求头、是否允许逗号，以及代理可以覆盖哪些请求头。除非请求头的语义明确允许，否则不要通过拼接文本来合并重复名称。`Set-Cookie`、`Content-Length`、`Host`、`Authorization` 和转发请求头都需要特殊处理或直接拒绝。我更倾向于让网关完全保留协议请求头和凭据请求头，然后只提供一小组允许代理提供的应用请求头。

有人有时会反对这一建议，因为任意自定义请求头能让通用 HTTP 工具显得更加灵活。灵活性受欢迎，是因为 API 需要多一个请求头时，不必再增加工具选项。但对于带凭据的代理流量，这仍然不是正确的默认设置。像 `idempotency_key` 或 `request_id` 这样的命名选项有明确的格式和负责人，而不受限制的请求头集合两者都没有。

## 只解析一次目标，并让各部分保持结构化

网关接受目标后，它就不再是一个字符串，而是由协议、主机、端口、路径、查询参数，有时还包括用户信息或片段组成的结构化值。使用了解标准的 URL 解析器解析一次，拒绝不允许的组成部分，然后将解析后的值直接交给 HTTP 客户端，不要再把它转换回模板字符串。

具体的允许列表取决于操作。调用某个供应商 API 的工具可能只需要 HTTPS、一个主机名和 443 端口。更宽泛的工具可能允许多个预配置来源。无论哪种情况，都应在创建请求前拒绝嵌入式凭据、片段、格式错误的主机名，以及任何解码后的回车或换行。片段不会进入网络，但接受它会给日志和审批页面增加不必要的混乱。

一个有用的请求边界如下：

```text
input destination
  -> decode once
  -> reject CR and LF
  -> parse URL
  -> require HTTPS
  -> require allowed host and allowed port
  -> reject user info and fragment
  -> pass parsed URL to HTTP client
```

这个顺序是经过考虑的。对原始字符串应用主机允许列表，可能会被类似 `https://allowed.example@other.example/` 的用户信息绕过。只在库规范化格式错误的值之后检查换行，可能会漏掉早期解析器已经接受的内容。应先按含义解析，同时在任何组件可能将控制字符渲染成语法之前拒绝它们。

不要通过拼接 `scheme + "://" + host + path` 来构建 HTTP 请求。这个模式会在最需要解析器保证的时刻，把结构化值重新变回文本。如果库要求分别提供主机和路径参数，就按照同样的控制字符规则验证每个参数，并使用库提供的结构化 API。

## 编码会在各层意见不一致时造成绕过

字面 CRLF 是每个人都会记得的测试。编码后的 CRLF 才能找出真正的漏洞。

代理可能会在 URL 组成部分中发送 `%0d%0a`。如果网关检查原始字符串，随后又在交给自定义渲染器之前进行 URL 解码，那么检查保护的就是错误的表示形式。换行会在验证之后才出现。如果不同层分别进行解码，双重编码，例如 `%250d%250a`，也会变得重要。

选择一个明确的规范化边界。例如，只解析一次 JSON，只在 URL 标准要求的地方进行百分号解码，验证渲染器实际要使用的表示形式，并禁止之后再进行通用解码。为每次转换保留测试。这比大型清理器看起来普通，但能让审查者有据可循。

每个代理控制字段的测试表至少应涵盖以下输入：

- 字面 `\r`、字面 `\n`，以及两者相邻形成的 CRLF
- 在接受百分号编码的地方测试 `%0d`、`%0a` 和 `%0d%0a`
- 如果其他组件可能稍后解码，测试 `%250d%250a`
- 包含冒号或空白的请求头名称
- 包含用户信息、意外端口或未批准主机的目标

不要只测试错误消息。断言对于被拒绝的输入，客户端传输从未运行。一个在创建或排队请求后才报告错误的验证器，已经违反了你真正关心的安全属性。

配置格式中还有一个相关陷阱。环境变量、YAML、JSON 和 shell 参数各自有不同的转义规则。一个看起来包含 `\n` 的测试夹具，可能只是两个无害字符，而生产环境中的 JSON 解码却会把它变成换行符。应通过接受真实代理调用的同一条解析路径来构建测试。

## 问题通常藏在便利代码里

危险代码往往是在最初的安全审查之后出现的。有人为新 API 添加自定义请求头，让调试代理可以配置，或编写一个为日志重新构造请求的重试函数。每项改动单独看都合理，但合在一起，就可能在原始网关已经使用安全客户端 API 后重新引入字符串渲染。

我发现这类问题时，会从输入一直追踪到套接字，而不是只搜索 `header` 这个词。查找围绕 `Host:`、`Authorization:`、`Cookie:`、`Content-Length:`、请求目标、代理命令、原始 HTTP 测试辅助工具和日志重放工具的字符串插值。也要搜索解码函数。入口处的安全验证器作用有限，因为后续路径可能接受另一种表示形式。

一个典型的故障包含四个部分。网关根据允许列表验证目标主机。它将目标的其余部分作为文本保存，交给低级客户端。重试辅助函数对这段文本进行百分号解码，让日志更容易阅读。随后辅助函数为重试操作写入原始请求行，于是 `%0d%0aX-Test:%20yes` 已经变成了消息语法。

这些代码行中没有任何一行写着“允许请求注入”。真正的问题是缺少一个不变量：当代码渲染 HTTP 消息时，不可信内容绝不能包含换行字符。将这个不变量放进一个共享验证器，并要求测试为原始渲染提供正当理由。

如果使用高级 HTTP 库，就始终使用它提供的高级接口。不要为了支持某个特殊端点而退回原始套接字。如果某个 API 确实要求异常的网络格式，就为该集成提供专用实现和严格的输入模型。不要因此削弱通用路径。

## 日志应记录被拒绝的意图，但不要重复机密

验证拒绝请求时，记录足够调查的信息：时间、代理会话或进程身份、操作名称、字段类别、拒绝原因，以及所提供值的安全摘要或长度。默认不要保存完整的请求头值。请求头经常包含令牌、个人数据或签名材料，一旦复制到日志中，就会变成另一个机密管理问题。

将尝试执行操作的审计事件与实际执行操作的事件分开。被拒绝的请求不应看起来像一次失败的网络调用，因为它从未到达网络。这一区分有助于事件响应，也能避免操作人员去追查根本没有收到请求的远程服务。

Sallyport 为代理运行保留 Sessions 日志，为调用保留 Activity 日志，两者都从加密的哈希链式审计日志投影而来。这为网关展示被拒绝的操作提供了合理位置，但输入规则仍然必须在 HTTP 渲染之前执行。

离线验证属性在这里很重要。`sp audit verify` 可以在没有保管库密钥的情况下验证密文上的链，从而确认记录的事件没有被篡改。但它无法告诉你验证器是否过于宽松。事件模式和测试必须让被拒绝的字段类别清晰可见，同时不保留危险数据。

## 测试序列化后的请求，而不只是验证器

针对 `contains('\r') || contains('\n')` 的单元测试既必要又不充分。它们只能证明某个函数拒绝两个字符，无法证明实际传输不会收到由解码、默认值、重试或自定义适配器引入的换行符。

使用本地测试服务器或记录传输来捕获传出的请求对象，并在技术栈允许时捕获其序列化字节。通过代理实际使用的同一个工具入口发送允许的输入。断言方法、权限来源、路径和完整请求头集合都正确。然后发送被拒绝的用例，断言记录传输没有看到任何请求。

一个紧凑的测试矩阵，比一长串含糊的安全测试更能发现回归：

```text
field                 input                         expected result
custom header value   "ok\r\nX-Added: yes"          reject before transport
custom header name    "X-Mode: injected"            reject before transport
destination path      "/v1/a%0d%0aX-Added:%20yes"   reject after decoding
allowed destination   "https://api.example/v1/a"    one request, expected host
```

如果语言适合，可以添加属性测试。围绕每个允许的字符类别生成控制字符，然后断言输入会被拒绝。模糊测试只有在契约明确后才有帮助。模糊测试可以发现奇怪的解析器情况，却无法告诉你任意请求头名称是产品决策还是意外结果。

测试失败时，先检查最终字节，再修改验证器。我见过团队不断添加替换规则，直到某个用例通过，最后才发现客户端折叠了空白或进行了两次解码。正确的修复通常是移除让原始语法成为可能的格式化器或解码器。

## 人工审批不能为格式错误的操作开脱

逐会话审批回答的是某个代理进程是否可以执行操作。逐次调用审批回答的是这次使用受保护凭据是否值得人工决定。两者都无法让含义不明确的请求变得安全。人无法可靠地检查很长目标或请求头值中的隐藏控制字符，而审批疲劳会把一个微妙的提示变成机械点击。

审批数据也应保持结构化。显示规范化后的主机、方法、路径、命名的请求头类别，并明确标出操作何时会添加凭据。在审批卡出现之前拒绝格式错误的输入。让人审批无效请求，相当于把解析器漏洞搬进了用户界面。

Sallyport 的保管库闸门、会话授权和逐次调用密钥设置，为人工控制提供了不同时间点。HTTP 操作边界仍然需要严格的渲染器，因为审批决定应当覆盖格式正确的操作，而不是一个点击后含义才会改变的字符串。

第一个实际改动不需要全面重写。找出所有接受代理控制的目标文本或自定义请求头的路径，在渲染前无条件拒绝 CRLF，并编写传输测试，证明被拒绝的输入不会发送任何内容。然后移除那些用字符串重新构造 HTTP 的便利代码。这个缺陷总会从那里重新出现。
