# 注入自定义身份验证请求头安全吗？

网关注入凭据时，必须把 HTTP 请求头当作协议语法，而不是一个方便的字符串字段。如果代理、配置界面、保险库导入或模板能把意外字节放进这个字段，网关就可能发出一个含义不同的请求。这个含义可能与批准界面、审计记录或目标服务所显示的内容不一致。

安全规则应该很明确：每个身份验证字段都由网关保留，在构造请求前立即验证最终字节，并拒绝格式错误的输入，而不是把它清理掉。这样可以在凭据离开本地存储前拦截换行、隐藏控制字符、Unicode 相似字符和重复字段名。

## 注入边界需要一份字节契约

自定义身份验证请求头应采用比通用 HTTP 解析更严格的契约。通用解析必须支持庞杂且混乱的互联网环境。网关只负责把秘密从保险库放进请求，任务更小，因此也应该接受更少的输入。

先把团队经常混在一起称为“请求头值”的四件事分开：

1. 配置的字段名，例如 `X-Api-Key`。
2. 为该字段存储的秘密字节。
3. 代理针对某次操作提供的请求头。
4. 交给 HTTP 客户端的最终外发请求头列表。

每一项都有不同的所有者。管理员或开发者配置名称。保险库拥有秘密。代理可以请求 HTTP 操作，也可以提供普通的应用请求头。只有网关负责组装最终的受保护字段。

这一点很重要，因为代理绝不应该拿到秘密，只为了把它和请求头名称拼接起来。它也能避免一个更隐蔽的错误：允许代理提供 `x-api-key`，然后网关再添加 `X-Api-Key`。HTTP 字段名不区分大小写，所以这两个是同一个字段的竞争实例，并不是两个互不相关的请求头。RFC 9110 将字段名定义为不区分大小写，并说明了只允许一个成员的单值字段。

对于凭据请求头，要明确规定字节契约：

- 名称只能使用 HTTP token 字符集，并以规范化的小写形式保存用于比较。
- 值必须是由 `0x21` 到 `0x7e` 之间的可打印 ASCII 字节组成的非空序列。
- 值不能包含空格、制表符、CR、LF、NUL 或任何其他控制字节。
- 网关只能添加一个受保护名称的实例。
- 代理请求一旦指定受保护字段，就必须失败，不能静默修改。

这项策略有意拒绝了一些通用字段允许的内容，重点就在这里。API 密钥、bearer 令牌或供应商凭据不应该需要制表符、开头空格或带重音字符才能安全地通过自定义请求头传输。如果供应商的凭据格式需要任意字节，就应按照供应商记录的传输表示，在存储前完成编码。协议要求可打印令牌时，base64url 是常见选择。不要让网关自行发明有损转换。

最诱人的替代方案是接受任意文本，先去除首尾空白，把控制字符换成空格，再依赖 HTTP 库拒绝剩余内容。这样会产生同一个凭据的多个表示。界面显示的可能是一个字符串，日志记录的可能是另一个字符串，而库发送的可能是第三个字符串。安全控制应该一次决定某个操作是否合法，而不是把操作改成另一个操作。

## CR、LF 和 NUL 必须在构造请求前失败

受保护请求头值中的任何位置都应拒绝回车（`0x0d`）、换行（`0x0a`）和 NUL（`0x00`）。单独的 CR 或单独的 LF 与常见的 CRLF 序列一样必须被拒绝。

HTTP/1.1 使用 CRLF 分隔字段行。RFC 9112 还允许接收方在某些情况下识别单独的 LF，而这正是外发网关不能假定所有下游组件反应一致的原因。该规范要求发送方不能在协议元素中产生单独的 CR，并废弃历史上的折叠字段行。

RFC 9110 对字段值的说法更加直接：CR、LF 和 NUL 都是无效且危险的，因为不同实现可能以不同方式解析它们。规范要求接收方拒绝这些字符，或在继续处理前将其替换为空格。替换是接收方的恢复规则，不是好的网关设计。网关知道自己正在创建新请求，因此应该拒绝输入，而不是继承歧义。

考虑以下到达请求构造器的值：

```text
team-secret\r\nX-Approval: bypassed
```

正确的验证器会发现两个禁止字节并拒绝操作。只搜索字面字符 `\\r\\n` 的弱验证器会看到无害的反斜杠。在模板展开前运行的弱验证器可能只检查 `${TOKEN}`，完全看不到展开后的值。把换行替换为空格的弱验证器，则会把明显应该拒绝的输入变成带有格式错误且已被改变凭据的请求。

唯一有用的检查，是在解码和插值完成后、HTTP 库接受请求头之前，对最终字节序列进行检查。结果可以采用这样的形式：

```text
reject header value: field=x-api-key reason=forbidden-byte byte=0x0a offset=11
```

这条消息用于本地操作记录，而不是返回给外部调用方。它只标识配置字段和错误字节，不要复制值本身。避免记录带引号、JSON 转义、base64 编码或规范化后的秘密。这些形式仍然属于秘密材料，而且通常会在日志中存留得比请求本身更久。

不要只针对 HTTP/1.1 做限制。HTTP/2 将字段放在压缩块中传输，而不是文本行中，但 RFC 9113 仍然禁止字段值在任何位置包含 NUL、LF 和 CR。它特别警告，未验证的字段在中间代理转换为 HTTP/1.1 时可能导致请求走私。HTTP/3 对之后进入基于文本的协议的字段也提出了同样的问题。

## 请求头名称需要 ASCII 所有权规则

拒绝非 ASCII 请求头名称。不要规范化、音译，也不要试图判断某个相似字符是否“足够接近”受保护名称。

HTTP 字段名是令牌。通用规则排除空格、控制字符、分隔符和非 ASCII 字符。HTTP/2 还要求名称使用小写，并拒绝不可见 ASCII、大写字母和高于 `0x7f` 的字节。网关可以采用更简单的规则：把配置名称验证为 ASCII HTTP token，按照 ASCII 规则转成小写，然后只保留原拼写用于显示。

比较操作必须基于字节。如果受保护名称是 `x-api-key`，下面这些代理提供的名称在转换为 ASCII 小写后都必须与它冲突：

```text
X-Api-Key
x-api-key
X-API-KEY
```

下面这些名称必须直接通过名称验证失败，而不是被修复：

```text
x-api‐key       // Unicode 连字符
x-api-kеy       // 西里尔字母 e
x-api-key\n
x api key
```

示例中的注释很重要。人眼可能看不出 ASCII 连字符和 Unicode 连字符的区别，也可能看不出 ASCII `e` 与西里尔字母 `е` 的区别。解析器不应该需要擅长排版。拒绝所有非 ASCII 请求头名称，歧义自然就消失了。

所有权与语法是两回事。名称在语法上有效，仍可能因为由网关拥有而被禁止。用小写 ASCII 保存受保护名称集合。在合并任何请求头集合之前，先把代理提供的每个请求头与这个集合比较。然后从头构造外发列表：

```text
通过验证的普通代理请求头
+ 网关拥有的凭据请求头
+ 网关拥有的请求元数据，如有
```

不要从代理列表开始，再覆盖选定条目。有些 HTTP 库会保留重复值，有些会合并值，还有些会把请求头暴露为映射，从而丢失原始重复项。一旦受保护字段进入了错误的集合，就不能再相信一次随意的覆盖操作。

## Unicode 规范化不是修复操作

不要在注入身份验证值时进行 Unicode 规范化。规范化在定义文本相等关系的系统中有合理用途，但秘密通常是不透明的字节序列，其含义由签发方决定。

Unicode 标准附录 #15 规定了 Unicode 规范化形式，用于描述规范等价或兼容等价文本表示之间的转换。但这不代表它们适合转换凭据。字符串 `café` 和 `café` 看起来可能相同，实际使用的码点序列却不同。供应商可能拒绝其中一个、接受其中一个、对其中一个进行哈希，或把它们视为两个不同的秘密值。网关无法猜测供应商选择了哪种行为。

这里有两个不同的规范化问题：

### 只有在协议定义了规则时，才规范化身份字段

产品可以定义用户名、显示标签或应用元数据的比较方式。应在产品拥有这层含义的边界处应用规则，并清楚记录所选择的表示。不要因为两个输入都以字符串形式到达，就把同一规则用于 HTTP 凭据注入。

请求头名称更简单。它们是协议标识符，不是用户 prose。拒绝非 ASCII 输入，并使用 ASCII 小写进行比较。NFC、NFD、NFKC、NFKD、Unicode 大小写折叠和相似字符骨架都不应参与这项决定。

### 绝不要用相似字符匹配来合并受保护请求头

Unicode 技术标准 #39 提供了用于安全审查的相似字符检测数据。它明确指出，相似字符骨架映射不应变成标识符规范化规则。这个警告是正确的：可以帮助标记可疑标签的映射，不是静默修改协议输入的安全规则。

如果希望提醒用户输入了奇怪的标签，可以在配置界面使用相似字符检测。不要在请求路径中使用它。请求路径应该只做两种判断：字段名是 ASCII 且已保留，或者无效；值是可打印 ASCII 且完全匹配，或者无效。

即使采用 ASCII 凭据策略，仍然需要测试 Unicode 规范化。这能发现界面和存储层中的隐藏转换。让分解形式的值、组合形式的等价值、不换行空格、零宽字符和从右到左控制字符分别经过每条输入路径。自定义身份验证值的正确结果，都是以同一通用类别拒绝：非 ASCII 字节或字符。结果不能取决于文本来自设置界面、导入文件还是代理参数。

## 重复字段属于授权失败

对于受保护的身份验证名称，即使两个值完全相同，重复字段也应拒绝操作。一个字段只有一个所有者，更容易审计和批准，也更少依赖目标服务的行为。

有人认为两个值相同就可以把重复字段视为无害。他们考虑的是数据清理，而不是授权。代理可能在重新排列字段之前或之后出现重复。目标服务可能选择第一个实例、最后一个实例、拼接多个实例、拒绝请求，或应用该字段自己的规则。中间代理也可能做出不同选择。这样一来，操作批准就不再描述一个明确无歧义的请求。

不要认为 `Authorization` 特殊，而所有 `X-...` 请求头都可以随意处理。供应商可以把自定义凭据字段定义为单值字段，而且大多数身份验证方案确实如此。网关应针对每个连接或凭据绑定维护明确的受保护名称列表。列表可以包含 `authorization`、`x-api-key`、`x-api-token` 或开发者配置的供应商字段，但不能依赖命名约定。

重复检测必须在 HTTP 客户端库把请求头列表转换为自己的表示之前完成。许多便捷 API 使用从不区分大小写的名称到字符串切片的映射。只要谨慎使用，它可以保留重复项，但可能隐藏顺序和来源所有权。其他 API 提供 `set` 方法替换一个值，提供 `add` 方法追加另一个值。只有在网关已经拒绝代理触碰受保护名称之后，这些操作才是安全的。

对每个代理提供的请求头使用以下决策表：

| 条件 | 网关结果 |
| --- | --- |
| 字段名无效 | 拒绝操作 |
| 以任意 ASCII 大小写形式出现受保护名称 | 拒绝操作 |
| 目标协议允许的普通名称重复 | 仅按照明确的逐名称规则保留 |
| 语义未知的普通名称重复 | 拒绝操作，或要求针对该连接配置规则 |
| 网关凭据字段 | 代理请求头通过验证后，只添加一次 |

第三行的范围有意设得很窄。`Accept` 可能具有列表语义，但自定义应用字段未必如此。如果网关不知道重复是否会改变含义，就不应该自行制定规则。通用的“转发所有请求头”功能，正是在这里悄悄变成绕过用户意图的方式。

## 验证每个转换边界，然后验证最终结果

一次早期验证并不够，因为请求值通常会在第一次检查后改变形态。每个可能创建、解码、拼接或替换字节的转换边界都需要局部不变量，最终请求构造器还需要进行决定性的验证。

实际流程可以有五个边界：

1. 配置请求头名称时，验证其 ASCII token 语法，并保留其小写形式。
2. 秘密进入存储时，验证凭据契约，并保存准确的已批准字节。
3. 代理提供操作时，验证普通请求头名称和值，然后拒绝受保护名称。
4. 模板、文件读取、环境变量展开和结构化请求解码完成后，再次验证值，因为这些过程可能产生新字节。
5. 即将调用 HTTP 库时，验证完整字段列表，并断言每个受保护名称都只出现一次。

最终检查具有最高权威，因为它看到的是真正要发出的对象。早期检查用于提供更好的错误消息，并防止错误状态进入系统，但不能替代最终检查。

这也是验证应该放在网关核心，而不是 MCP 工具描述、提示词或代理辅助程序中的原因。这些位置可以描述预期请求，却不能保证最终到达套接字的内容。拥有凭据注入权的闸门，必须拥有最后一次字节检查。

Sallyport 实现了这个边界中有用的部分：应用自己执行 HTTP 凭据注入，代理拿到结果，而不是秘密。对于自定义请求头凭据，操作层仍应在到达注入点前，拒绝代理提供的保留字段副本。

让验证错误保持确定性。如果请求在第 8 个字节包含 CR，同时又有重复的受保护请求头，应按照固定顺序报告最早的失败，例如依次检查无效名称、受保护名称冲突、值无效和最终数量。确定性有助于让测试可靠，也能防止攻击者利用错误差异了解存储凭据的更多信息。

## 让验证器小到足以审计

具有严格契约的短验证器，比带有长串例外的通用清理器更安全。下面的 Go 示例有意只允许自定义身份验证值使用可打印 ASCII 字节。它不会去除空白、规范化、解码或改写输入。

```go
package authheader

import (
    "fmt"
    "strings"
)

func canonicalName(name string) (string, error) {
    if name == "" {
        return "", fmt.Errorf("empty field name")
    }
    for i := 0; i < len(name); i++ {
        b := name[i]
        ok := b == '!' || b == '#' || b == '$' || b == '%' || b == '&' ||
            b == '\'' || b == '*' || b == '+' || b == '-' || b == '.' ||
            b == '^' || b == '_' || b == '`' || b == '|' || b == '~' ||
            ('0' <= b && b <= '9') || ('A' <= b && b <= 'Z') ||
            ('a' <= b && b <= 'z')
        if !ok {
            return "", fmt.Errorf("invalid field-name byte 0x%02x at offset %d", b, i)
        }
    }
    return strings.ToLower(name), nil
}

func validateCredentialValue(value string) error {
    if len(value) == 0 {
        return fmt.Errorf("empty credential value")
    }
    for i := 0; i < len(value); i++ {
        b := value[i]
        if b < 0x21 || b > 0x7e {
            return fmt.Errorf("invalid credential byte 0x%02x at offset %d", b, i)
        }
    }
    return nil
}

func rejectProtected(headers [][2]string, protected map[string]struct{}) error {
    for _, h := range headers {
        name, err := canonicalName(h[0])
        if err != nil {
            return err
        }
        if _, found := protected[name]; found {
            return fmt.Errorf("agent supplied protected field %q", name)
        }
    }
    return nil
}
```

这个示例允许值中出现 `:`，因为可打印 ASCII 包含它。对许多 API 令牌来说，这没有问题。如果目标服务只接受有文档规定的子集，就在针对该连接的验证器中执行供应商规则。不要让全局网关策略擅自猜测冒号、斜杠或等号可疑。

Go 中使用 `string` 并不意味着验证器已经具备 Unicode 感知能力。对 Go 字符串按索引访问得到的是字节。多字节 UTF-8 序列会包含高于 `0x7e` 的字节，因此这份契约可以干净地拒绝它。如果运行时存储的是 Unicode 标量值而不是字节字符串，应先编码为准确的外发字节表示，再运行字节验证。

调用方不能把 `validateCredentialValue` 暴露成清理辅助函数。它只有两种有效结果：批准未改变的字节，或返回错误。审查时应对名为 `sanitizeHeader`、`cleanHeader` 或 `normalizeToken` 的方法保持警惕，因为这些名称会诱使调用方在以为自己保护秘密的同时，实际改写秘密。

## 测试矩阵需要恶意表示，而不只是恶意字符串

测试套件应该证明，进入外发请求的每条路径都会经过同一个最终验证器。不能只直接用明显的 CRLF 输入调用验证器。

先对原始值使用表驱动单元测试：

```go
cases := []struct {
    name  string
    value string
    want  bool
}{
    {"ordinary token", "mF_9.B5f-4.1JqM", true},
    {"carriage return", "abc\rdef", false},
    {"line feed", "abc\ndef", false},
    {"crlf", "abc\r\ndef", false},
    {"nul", "abc\x00def", false},
    {"leading space", " abc", false},
    {"trailing tab", "abc\t", false},
    {"composed Unicode", "caf\u00e9", false},
    {"decomposed Unicode", "cafe\u0301", false},
}
```

然后使用看起来很普通的测试夹具测试完整操作路径：

- 一个通过 `\n` 解码接收换行的 JSON 请求。
- 一次配置导入，文件中的令牌末尾带有换行。
- 一个模板结果，其中缺失变量变成空字符串。
- 一个同时包含 `X-Api-Key` 和 `x-api-key` 的代理请求。
- 一个名称中使用非 ASCII 相似字符的代理请求，该请求必须因名称语法失败，而不是绕过受保护名称匹配。

文件末尾的情况很容易被忽略，却能发现真实问题。开发者把令牌复制到文本文件中，不知不觉加上最后的换行，然后使用命令替换或导入器读取它。某条 shell 命令路径可能会删除末尾换行，而文件读取器在另一条路径中保留它。正确的网关行为应该一致：不去除换行，而是要求操作员修正存储的凭据。

围绕最终构造器添加属性测试。生成包含每个控制字节、高于 `0x7f` 的字节和可打印 ASCII 的短字节字符串。断言只有非空且完全落在允许范围内的字符串能够通过。为每个受保护字段名称生成大小写变体，并断言所有 ASCII 大小写变体都会拒绝代理取得所有权。这些测试可以发现只检查成对 `\r\n`、忘记单独 LF，或在查询受保护集合之后才转成小写等错误。

最后，使用一个集成监听器捕获 HTTP 库实际收到的请求头。由于网关应该先拒绝，它不应该看到任何格式错误的受保护值。监听器验证的是构造过程，不是安全策略。如果集成测试发现第二个受保护字段，即使目标服务器本来也会拒绝请求，也应将其视为网关缺陷。

## 宽松的回退行为会掩盖真正需要修复的问题

把禁止字节替换为空格、去除空白，或保留“最后一个”重复字段很受欢迎，因为这样演示更容易成功。但这会让外发凭据的来源变得难以重建。

看看去除空白的情况。操作员从文件中导入 `token-42\n`。一条代码路径将它去除换行后发送 `token-42`，另一条代码路径保存原始值并在之后拒绝它。于是操作员看到本地测试成功，却发现网关失败。最终一定有人会向网关加入去除空白的逻辑，以消除这种不一致。几个月后，模板生成了 `token-42\r\nX-Role: admin`，同一段“去除空白”代码可能只删除最后的换行，却保留内部 CRLF。所谓“贴心”的行为同时掩盖了源头错误和安全边界。

正确的修复很简单。定义一种可接受的凭据格式，拒绝其他所有值，并让导入工具说明字节为何失败。配置界面可以显示“偏移量 8 处有结尾 LF”，但不暴露令牌。命令行导入器可以输出同一类别并以非零状态退出。让用户修正源头，而不是教每个后续层猜测该怎么做。

审计记录应区分授权拒绝和传输失败。记录代理进程请求了某个操作、操作在发送前被拒绝、涉及哪个配置的凭据绑定，以及验证类别。不要调用目标服务，也不要创建看起来像远程拒绝的虚构 HTTP 响应，因为远程请求根本没有发生。

Sallyport 分离的会话和活动记录为这类拒绝提供了合适的位置：会话显示哪个代理运行尝试了操作，操作记录则可以显示本地验证阻止了发送。记录应该在不保存被拒绝的秘密或其转义近似值的情况下，依然有用。

## 规则严格，是因为网关拥有秘密

普通 HTTP 客户端可以容忍宽泛输入，因为它发送的往往是调用方本来就拥有的数据。凭据网关承担着不同的责任。它决定代理是否可以触发一个由秘密支持的操作，因此必须基于语法不会在处理中途改变的请求做出决定。

拒绝自定义身份验证值中的控制字符和非 ASCII 字符。保留身份验证名称，并按照 ASCII 小写规则比较。拒绝重复的受保护字段。不要在发送前规范化或去除凭据空白。验证每条输入路径，最后再验证一次完整请求头列表。

如果供应商的身份验证格式无法满足这份契约，就记录供应商专用的编码方式，并在秘密进入请求构造器前完成编码。不要因为某个集成带来了格式混乱的令牌，就放宽网关，让它接受含糊的文本。第一个格式错误的值就应该在闸门处停止，返回说明字节问题的拒绝结果，同时不让秘密暴露。
