# 规范化 IPv6 地址，让审批目标清晰可比

审批界面如果直接显示原始 IPv6 字符串，就等于要求人在时间压力下充当解析器。这是设计上的错误。系统必须把请求解析成类型明确的端点，拒绝含义不清的输入，比较类型化的值，并显示一种稳定的形式，让人下次看到同一请求时也能认出来。

IPv6 表示法给了攻击者和普通软件许多让同一目的地看起来不同的方式：压缩零字段、字段中的前导零、URL 必需的方括号、嵌入式 IPv4 尾部，以及接口作用域。它们都不意味着 IPv6 可疑，但也意味着审批目标需要比一个恰好包含主机名的字段更严格的处理。

## 一个地址可以穿着几种字符串外衣出现

字符串 `2001:db8:0:0:0:0:0:9`、`2001:0db8::9` 和 `2001:db8::9` 指向同一个没有作用域的 IPv6 地址。如果审批卡保存了一种字符串，而后续请求提供了另一种字符串，直接比较字符串就会认为它们不同。如果允许列表接受一种写法，而审计搜索期待另一种写法，操作人员恰恰会在最需要追查时失去线索。

IPv6 有八个 16 位字段。每个字段都可以省略前导零，然后把一段连续的全零字段替换为 `::`。`::` 标记对人很方便，但它去掉了作者省略了多少个字段的信息。解析器会恢复这些字段，并返回等同性判断真正关心的唯一表示：16 个地址字节。

人们经常混淆这两个概念：规范化文本用于检查，解析后的字节用于比较。仅仅规范化并不能完成授权，它只是确保显示给人的形式不受调用方写法影响。

请将以下内容作为不同的数据项处理：

- `raw_input`：从调用方收到的完整主机文本和周围的 authority 语法
- `address`：严格解析后的 16 个字节
- `scope_id`：输入合法提供的接口作用域
- `display_host`：根据类型化地址生成的规范化字符串
- `port`、`scheme` 和请求详情：描述实际操作的字段

将原始输入保存在审计记录中。它能说明代理请求了什么。不要把它用作比较标记，也不要让它成为屏幕上唯一显示的内容。

## URI 方括号是语法，不属于主机

URI authority 中的 IPv6 字面量必须加方括号，因为冒号已经用于分隔主机和端口。RFC 3986 定义了这种形式：`https://[2001:db8::9]:8443/v1/jobs`。地址是 `2001:db8::9`，方括号用于告诉 URI 解析器主机在哪里结束。

这条规则会导致一种常见错误。开发者把 `[2001:db8::9]` 传给地址解析器，遭到拒绝，于是不断删除字符直到解析成功，之后又接受了格式错误的 authority，因为两个解析器的理解已经不一致。另一种相同的错误，是把方括号和地址一起保存，导致一条记录是 `[2001:db8::9]`，另一条是 `2001:db8::9`。它们本来就不该进入同一个存储字段。

应在值进入系统时所处的语法边界进行解析。对于 HTTP 目标，先使用符合标准的 URI 解析器解析完整 URI。根据该解析器提取主机、端口、协议、路径和查询参数。然后只把主机值传给 IPv6 解析器，不要包含 URI 方括号。对于直接传给 SSH 的主机参数，应使用 SSH 命令接口文档规定的语法，不要假设它是 URI。

顺序很重要。考虑这个请求：

```text
https://[2001:0db8:0:0:0:0:0:9]:8443/admin
```

正确的内部结果应当是：

```text
kind: ipv6
address: 20010db8000000000000000000000009
scope_id: null
display_host: 2001:db8::9
scheme: https
port: 8443
path: /admin
```

现在，审批界面可以显示 `https://[2001:db8::9]:8443/admin`。不要悄悄把它压缩成 `2001:db8::9`，因为端口和路径都是用户需要评估的内容。反过来，也不要仅仅因为调用方的写法先到，就保留其中带有多余前导零的形式。

裸地址没有方括号。包含 IPv6 字面量的 URI authority 有方括号。让这条规则保持明确且可预测。

## 显示形式应遵循 RFC 5952

RFC 5952 建议使用小写十六进制、字段中不使用前导零，并用 `::` 表示最长的连续零字段。两个零字段长度相同时，选择前面的一个。它还规定，单个零字段不应使用 `::`。这些规则能让面向人的显示形式保持稳定。

例如，可以按如下方式规范化这些值：

```text
2001:0DB8:0000:0000:0000:0000:0000:0009  ->  2001:db8::9
2001:db8:0:1:0:0:0:1                    ->  2001:db8:0:1::1
2001:db8:0:1:0:0:0:0                    ->  2001:db8:0:1::
2001:db8:0:1:0:0:0:2                    ->  2001:db8:0:1::2
0:0:0:0:0:0:0:1                         ->  ::1
```

标准的建议比看起来更有用。它让操作人员在日志中只需搜索一种写法，也能防止调用方在 `2001:db8::9` 已经获得批准后，通过 `2001:0DB8::9` 伪装成一个新目的地。

不要自己编写通过冒号分割字符串、再统计空字符串数量的格式化器。IPv4 尾部、错误的双重压缩和作用域语法会让这种做法非常脆弱。请使用执行操作的语言中经过测试的 IPv6 解析器，保留其 16 字节输出，并根据这些字节生成显示形式。除了 RFC 5952 的示例，还要测试等长零字段的情况。

也不要夸大 RFC 5952 的作用。它描述的是推荐的文本表示形式，并不决定 `::ffff:192.0.2.7` 和 `192.0.2.7` 是否应获得相同的授权。这是一个会带来实际后果的产品决策。

## IPv4 映射形式需要明确的比较规则

`::ffff:192.0.2.7` 是 IPv4 映射的 IPv6 地址。最后 32 位承载 IPv4 地址，前面的模式标识这种映射形式。当应用在 IPv6 套接字上接受 IPv4 连接时，操作系统经常会暴露这种形式。它在日志中出现得足够频繁，若把它当成奇怪的边缘情况，之后一定会造成混乱。

内部有两种合理的模型。请为每个授权边界选择一种，并在界面中说明。

第一种模型保留地址族。`::ffff:192.0.2.7` 仍然是一个类型为 `ipv6` 的 16 字节 IPv6 地址，而 `192.0.2.7` 仍然是一个类型为 `ipv4` 的四字节 IPv4 地址。两者永远不会比较为相等。当审批描述的是以特定形式请求的网络连接时，这是更安全的默认选择，因为它不会在不同地址族之间悄悄扩大决策范围。

第二种模型会针对严格限定的对端身份场景，将映射地址转换为 IPv4。在这种模型中，解析器同时记录原始地址族和 `embedded_ipv4` 值。比较代码明确规定，在这一个用途下，映射形式等于其中嵌入的 IPv4 地址。审计记录仍然保留原始形式，并说明比较使用了这种转换。

真正有问题的是意外转换。许多标准库都提供便利方法，可以把映射地址转换成 IPv4，却不说明它是以什么形式到达的。用于连接日志时，这很方便；如果开发者把它复用于审批标记，就很危险。这样一来，针对 `192.0.2.7` 编写的规则可能会在没人做出决定的情况下批准 `::ffff:192.0.2.7`。

请使用能明确体现选择的测试对：

```text
input A: 192.0.2.7
input B: ::ffff:192.0.2.7

strict endpoint comparison: different
explicit peer-identity projection: same IPv4 peer, if the product says so
```

不要把所有带点号尾部的 IPv6 字符串都视为映射地址。解析器必须验证完整前缀以及 IPv4 部分的位置。RFC 4291 定义了 IPv4 映射地址，同时也允许在 IPv6 文本中使用 IPv4 兼容表示法。格式化器应保留足够的类型信息，避免把所有带点号尾部的地址都当成同一种形式。

## 作用域标识符属于本地接口

作用域标识符能将原本含义不明确的作用域地址转换为可使用的本地目的地。`fe80::1%en0` 表示接口名为 `en0` 的链路本地地址。没有作用域时，拥有多个网络接口的主机无法知道调用方指的是哪条链路。

RFC 4007 将其描述为作用域区域的概念，而不是附加在地址末尾的装饰。接口名称或索引只对解析它的主机有意义。在另一台机器上，`en0` 可能代表不同的接口，也可能根本不存在。因此，作用域标识符不适合作为可移植的审批目标。

对于直接的本地套接字操作，只有在平台解析器验证了作用域，并且操作在同一台机器上执行时，才接受作用域。操作系统提供接口索引时，应将规范化后的数字接口索引作为比较值。也可以保留调用方提供的接口名称用于审计，但当硬件和网络配置变化时，名称可能改变。

对于 HTTP URI，规则更严格。RFC 6874 规定，方括号字面量中引入作用域标识符的百分号必须编码为 `%25`。因此 URI 应使用如下形式：

```text
http://[fe80::1%25en0]/status
```

URI 解析器必须在正确的阶段解码它。不要在解析前对整个 URL 进行百分号解码，因为通用解码器可能会改变分隔符，从而生成与调用方提供的不同 URL。应先解析 URI，提取主机字面量，然后对该组件应用作用域字面量所需的规则。

大多数面向代理的 HTTP 审批流程都应拒绝带作用域的字面量，并向调用方说明原因：链路本地地址只有与特定本地接口绑定后才有意义。要求代理使用稳定的 DNS 名称、没有作用域的地址，或经过明确配置的本地端点，可以让其他人也理解这项审批。只有当产品确实要操作本地网络设备，并且会清楚显示接口作用域时，才允许例外。

## 主机比较的范围小于操作审批

规范化地址只能解决一小类欺骗问题。它不会让 `https://[2001:db8::9]/` 等同于 `https://[2001:db8::9]:9443/delete`，也不会因为 SSH 目标的主机文本看起来熟悉，就让它变得安全。

类型化的审批目标应保留原始字符串隐藏的边界。对于 HTTP，通常至少包括协议、主机类型和地址字节、解析默认端口后的端口、方法，以及能够显示路径的请求描述。是否需要将请求头和请求体摘要纳入决策，取决于具体操作。如果一个 bearer 凭据可以调用多个无关 API，凭据身份也应出现在提示中。

对于 SSH，要区分网络端点和远程命令。批准打开 SSH 连接，并不自动意味着允许运行 `sudo`、修改部署文件或转发端口。如果工具在一次连接后可以执行多种操作，界面必须说明会话授权覆盖哪些内容，并为每次调用保留相应的命令记录。

这正是清晰的数据模型发挥作用的地方。它能阻止一种看似合理却错误的建议：把规范化主机放进扁平的允许列表，然后宣布工作完成。扁平主机列表很受欢迎，因为容易解释。它们的问题在于，主机只是操作的一部分，之后的 DNS 结果、端口、路径或命令都可能改变最终效果。

一个有用的审批记录可以是这样：

```text
transport: https
host_kind: ipv6
host_bytes: 20010db8000000000000000000000009
host_display: 2001:db8::9
scope_id: null
port: 8443
method: POST
path: /v1/releases
credential_ref: deploy-api
raw_authority: [2001:0db8::9]:8443
```

原始 authority 用于记录请求。类型化字段用于比较。显示字段让人可以读取稳定的目标描述。不要用其中一个字段替代其他字段。

## 拒绝规则应保持简单而严格

当输入不符合所在位置的语法时，解析器应拒绝继续处理。错误消息可以清楚有用，但系统不应通过猜测调用方意图来修复地址。接受近似合法输入的规范化器会创造第二套语法，让安全审查人员难以还原真实规则。

出现以下任何问题时，都应拒绝 IP 字面量请求：

- 存在多个 `::` 压缩标记，或展开后字段数量过多
- 十六进制字段中出现非十六进制字符
- IPv4 尾部位于 IPv6 文本语法不允许的位置
- 将方括号传给裸地址解析器，或 URI authority 中缺少方括号
- 在操作无法绑定本地接口的上下文中使用作用域标识符

如果 URI 解析器和套接字解析器对字面量的理解不同，也应拒绝。不同库在百分号编码、特殊 IPv4 形式和主机解析方面长期以来做过不同选择。一个组件批准的文本，如果另一个组件连接到的是不同目标，就形成了授权漏洞。

建立一套小型的跨层测试语料。让每个案例依次经过生产环境使用的 URI 解析器、主机提取器、IP 解析器、格式化器、比较器和传输构建器。对于接受的输入，同时断言规范化显示形式和实际套接字目的地。对于拒绝的输入，断言不会生成传输对象。

紧凑的测试语料应包含 `::`、`::1`、完全展开的地址、两个等长的零字段、带端口的方括号 URI 字面量、映射 IPv4 值、格式错误的点号尾部、带作用域的链路本地字面量，以及 URI 中经过编码的 `%25` 作用域。再加入环境中的代理实际产生过的输入形式。来自真实审批的回归测试没有解析器技巧那么炫目，却更有可能抓住下一次错误。

## 审批卡应展示规范化结果

如果卡片隐藏了发生变化的部分，人就无法评估端点。应突出显示规范化后的目标，并在提交写法不同的情况下同时显示原始写法。一行简单的文本，例如 `Submitted as [2001:0DB8:0:0:0:0:0:9]:8443`，可以向审查者提供证据，而不必让他们在脑中逐个解码字段。

卡片还应明确标出特殊形式。不要把映射地址作为普通 IPv6 字面量展示，而应标注为 `IPv4-mapped IPv6`。带作用域的地址应显示接口名称，并说明它只在该接口所在的本地环境中有效。如果操作策略会将映射地址转换为 IPv4，应在卡片中说明这一决定。等价关系被静默处理，正是审查者最容易感到意外的地方。

对于后果重大的调用，应要求人审批完整操作，而不只是规范化后的主机。简洁的展示仍然可以包含必要细节：

```text
POST https://[2001:db8::9]:8443/v1/releases
Uses credential: deploy-api
Submitted host spelling: 2001:0DB8:0:0:0:0:0:9
```

Sallyport 在最关键的地方遵循了这种分离：代理永远拿不到 API 或 SSH 密钥，由应用执行操作并返回结果。它的按会话授权可以确认是谁启动了一次运行，按调用控制则能让敏感操作保持可见，不会把之前的批准当成一张无限制的空白支票。

## 审计记录需要原始文本和规范化含义

如果只保存一种表示，事件复盘时经常无法同时回答两个相互冲突的问题：代理实际发送了什么，传输层连接到了什么目的地？两者都要保存，并明确说明哪个值驱动了决策。

审计事件应包含原始输入、解析后的主机类型、规范化显示形式、使用明确编码保存的地址字节、存在时的作用域身份，以及审批覆盖的全部操作字段。序列化后对事件进行哈希只有在序列化定义稳定时才有用。否则，格式化器一旦变化，同一个语义事件也可能产生不同记录。

不要在生成显示形式后删除非规范化输入。它可能揭示有问题的客户端、绕过尝试，或某个后来能够解释事件的无害库行为。应将它作为证据保留，但不要让搜索面板把它变成同一端点的另一个误导性身份。

Sallyport 的 Activity journal 和 Sessions journal 都来自同一份加密、哈希链式审计日志，而 `sp audit verify` 可以在离线状态下对密文验证这条链，无需保险库密钥。当审查者需要区分奇怪的写法和不同的已执行操作时，这种结构尤其有用。

在重新设计整个审批层之前，先做一个测试：用完全展开的形式和 RFC 5952 形式提交同一个目标。如果系统产生了不同的审批身份、不同的审计搜索结果，或不同的允许决策，说明解析边界仍然放错了位置。
