# Unicode 主机名提示如何批准错误的目的地

代理 HTTP 请求的审批卡是安全边界，不是礼貌性的通知。如果卡片显示一个目的地，而 HTTP 栈却把凭据发送到另一个目的地，用户就没有在充分知情的情况下作出决定。真正替他们作决定的是网关，只是用了更好看的字体。

Unicode 很容易制造这种错位，因为主机名有多种看起来相关、实际用途却不同的形式。用户看到的可能是 Unicode，DNS 接收的是 ASCII 兼容标签，URL 解析器可能会规范化分隔符、百分号转义、大小写、IPv4 表示法或末尾的空标签。重定向处理器还可能用另一套代码路径解析下一个位置。如果某一层审批了一种表示形式，而另一层却用不同的表示形式建立连接，凭据就可能被发送到错误的 authority。

解决办法不是“禁止非 ASCII 域名”。这样会惩罚合法的国际化名称，却仍然挡不住误导性子域、userinfo、数字 IP 形式和重定向跳转等 ASCII 伎俩。正确做法是让一个解析后的 authority 记录成为唯一对象，既用于构建审批界面，也用于授权注入凭据。然后在进入生产环境前，先测试那些难看的输入。

## 审批卡必须描述即将执行的请求

审批提示必须来自与 HTTP 客户端实际执行的请求相同的规范请求对象。否则就会产生两个事实来源：一个面向人的显示字符串，以及一个供代码使用的传输字符串。主机名混淆正是在这两个来源之间的裂缝中变成凭据泄露的。

按以下顺序构建请求：

1. 将原始 URL 视为不可信输入，并使用网关选定的 URL 实现解析一次。
2. 拒绝不支持的 scheme、格式错误的 authority、嵌入式凭据，以及规范形式不符合主机名规则的输入。
3. 生成结构化 authority 记录，其中包含 scheme、规范主机名、有效端口，以及输入是否带有末尾根点的标志。
4. 根据该记录渲染审批卡，然后把同一记录交给选择和注入凭据的代码。
5. 如果重定向、重试目标、代理目标或备用地址改变了 authority 记录，就要求重新授权。

原始 URL 是证据，不是权威。应将它保存在活动记录中，因为它有助于说明代理尝试了什么。但不要单独用它进行凭据匹配、审批缓存或目的地显示。

对于 HTTP，authority 不只是一个裸主机名。`https://api.example.test:8443/` 和 `https://api.example.test/` 可能对应不同的服务和证书策略。解析器确定默认端口后，显示时可以省略默认端口，但非默认端口必须显示在卡片上。同样，也要显示 scheme。即使主机名文本完全相同，把 bearer token 通过 `http` 而不是 `https` 发送，也会改变风险。

RFC 3986 将 host 定义为 IP 字面量、IPv4 地址或注册名称。这对语法很有帮助，却没有告诉审批系统什么样的名称能让人安全识别。应把解析作为前置条件，再根据解析结果构建面向安全的显示形式。

## Punycode 是标识符，不是友好名称

Punycode 的存在，是为了让 DNS 能够使用 ASCII 携带国际化标签。A-label 以 ASCII 的 `xn` 前缀和两个连字符开头，U-label 则是对应的 Unicode 形式。只有当标签符合适用的 IDNA 规则时，两者之间的转换才是可逆的。这一区别很重要，因为仅仅看起来像 A-label 的字符串，并不会自动成为有效或适合显示为 Unicode 的名称。

RFC 5891 要求支持 IDNA 的查找应用，在把疑似 A-label 的内容当作 U-label 处理前先进行验证。尤其是当应用为本地语言显示而解码 A-label 时，应确认重新转换后得到的结果与原始标签一致。这个往返验证并非学术问题。没有它，界面可能把格式错误的 ASCII 转成令人信服的 Unicode 文本，让审核者看到一套传输层实际上从未表达过的说法。

输入为国际化名称时，审批卡应同时显示两种形式：

```text
Destination
https://xn--example-ascii-label.test
Unicode rendering: example-unicode-label.test
Port: 443
Credential: deploy token
```

将规范 ASCII 主机名单独放在一行，并使用等宽字体或其他高可读性的样式。不要把它藏在展开控件后面。Unicode 形式有助于人识别合法服务，但 ASCII 形式才是能在字体和文字系统之间保持稳定的持久标识符，也应当用于匹配允许列表、审计记录、缓存键和实际 DNS 查找路径。

不要因为某个标签以 A-label 标记开头，就解码每个标签。应执行解码、验证、重新编码和比较。如果验证失败，就显示原始 ASCII 标签，并明确警告该主机名未通过 IDNA 验证。安全操作是拒绝注入凭据，而不是猜测代理想表达什么。

这里有一条常见建议其实并不可靠：“始终显示 Unicode，因为 Punycode 看起来可疑。”这会带来友好的界面，却移除了唯一能跨字体和文字系统保持稳定的表示形式。只显示 ASCII 对合法国际化服务同样不友好。应同时显示两者，以 ASCII 为权威，并确保两者都来自同一个经过验证的解析结果。

## 混合文字应触发警告，而不是错误结论

混合拉丁字母和西里尔字母的主机名，可能与 ASCII 主机名几乎一模一样。例如，一个标签可以包含看起来像拉丁字母 `a`、`c`、`e`、`o`、`p` 或 `x` 的西里尔小写字母。审核者快速浏览审批卡时可能会漏掉替换，尤其是名称中有意义的部分很短时。

Unicode 技术标准 #39 将这类情况称为混合脚本混淆字符，即字符串在视觉上容易混淆，但结果不属于单一脚本混淆字符。该标准还描述了整套文字混淆字符，即标签只使用一种文字，却看起来像另一种文字中的标签。标准也坦率说明了局限：混淆程度取决于字体、上下文塑形和人的熟悉程度。检测器可以识别风险，却无法证明某个名称具有欺骗性。

正因为如此，网关不应把混合脚本检查变成自动阻止列表。使用日语、韩语、中文、希腊语或多种文字的组织，可能拥有被简单规则误判的合法域名。相反，应利用这个信号改变审批方式：

- 将任何检测为混合脚本的标签标记为必须逐次明确审批。
- 审核者展开主机名时，在详情视图中显示文字系统和码点。
- 将主机名的 UTS #39 skeleton 与受保护的内部名称以及明确配置的高价值目的地进行比较。
- 如果目的地与受保护名称混淆，即使此前从未出现过，也禁止自动复用凭据。
- 在活动日志中记录检测器版本和结果，以便后续审核重现该决定。

Skeleton 是用于比较的产物，不是替代主机名。UTS #39 明确指出，skeleton 结果不适合用作标识符进行显示、存储或传输。应保存规范 ASCII 主机名。Skeleton 只用于回答一个范围很窄的问题：“这个候选名称是否像某个我们决定需要额外保护的名称？”

受保护名称集合应当小而且有明确意图。可以包括自己的部署端点、软件包注册表、源代码托管主机、身份提供商以及支付或生产 API。不要生成一张包含所有可能重要公共域名的巨大列表。这样会制造没人阅读的警告，而真正重要的警告也会变得普通。

## 不可见码点会把审核变成渲染问题

不可见字符比明显的非 ASCII 字符更糟，因为审核者无法可靠地看到它们。根据字符和所涉及的软件，它们可能影响连接行为、文字方向、换行或字形选择。有些字符可能被 IDNA 拒绝，另一些则可能被不同的库映射或以不同方式处理。审批流程不能依赖审核者发现一处没有墨迹的地方。

团队经常把下面两个问题混在一起，但它们其实是分开的：

1. 这个码点在 IDNA 主机名标签中是否有效？
2. 这个码点是否会让审批显示具有误导性、让日志产生歧义，或让比较代码表现不一致？

第一个问题的答案为“是”，并不能解决第二个问题。IDNA 对某些码点有上下文规则，其查找验证也会拒绝多类无效输入。但 HTTP 网关还要处理原始 URL、界面渲染、JSON 日志、复制的文本，以及可能不是 DNS 的主机形式。安全审核需要覆盖整条路径的规则，而不仅仅是 DNS 有效性。RFC 5891 说明 IDNA 用于域名，而不是任意自由文本。应将它限制在主机名处理中，不要假装它能清理完整 URL。

对于审批界面，如果解析后的 Unicode 显示形式包含默认可忽略码点、双向控制字符，或所选 IDNA 实现报告为无效的字符，应在注入凭据前拒绝该主机名。这比“先尝试查找再说”严格得多。匿名公共浏览器可以选择尝试查找，但凭据网关不应在调查歧义字符串的同时发送 token。

记录被拒绝的输入时，应保存两种形式：安全转义的码点序列，以及保存在不会发生静默规范化的字段中的原始字节序列或 UTF-8 文本。良好的活动记录可以包含这样的文本：

```text
raw_host_escaped: "api\\u200d.example.test"
parsed_host_ascii: null
rejection: "default-ignorable code point in hostname display"
credential_attached: false
```

不要把原始主机名放进操作人员会快速扫过的句子里。日志经常会在事件响应期间成为下一个审批界面。如果不可见字符在终端中再次变得不可见，你只是把陷阱换了个地方。

## 末尾的点是数据，即使 DNS 把它当作根

以点结尾的主机名不是装饰性标点。在 DNS 表示形式中，最后一个点表示根标签，并标记该名称为绝对名称。RFC 1034 解释过，每个完整域名都以根标签结尾，因此打印形式也以点结尾。

但这并不意味着所有 HTTP 组件都会以相同方式处理 `api.example.test` 和 `api.example.test.`。一个解析器可能在 hostname 属性中保留该点，另一个可能在连接前将其规范化。证书验证器、Cookie 实现、代理、允许列表或重定向缓存也可能采用第三种行为。唯一安全的规则，是决定网关是否将两者视为等价，并在处理请求的每个组件中测试这一决定。

对于凭据网关，我建议保留输入事实，并且只在有明确记录的比较规则下规范化 authority。保存以下两项：

```text
input_host: "api.example.test."
canonical_dns_name: "api.example.test"
had_root_dot: true
```

随后使用 `canonical_dns_name` 匹配目的地记录，但如果输入带有末尾点，也要在审批卡上显示出来。审核者应看到代理提供的是一种略显异常的形式。普通的点不应悄悄扩大 authority。如果凭据已获准用于 `api.example.test`，请求 `api.example.test.` 只有在解析器、解析器、TLS 名称验证器和网关比较规则都确认这是同一个目的地时，才能复用该审批。

不要在解析前用通用字符串代码删除末尾的点。通用清理往往会逐渐变成“清理主机名”，接着又删除本应触发拒绝的空白、标点或 Unicode 分隔符。先解析，再根据书面定义的语义规则规范化属性。

## 解析器测试语料需要恶意案例，而不是幻灯片里的示例

主机名测试套件应验证解析、规范化、显示、凭据匹配、连接建立和日志记录之间的一致性。测试一个把 Unicode 标签转换为 ASCII 的辅助函数很有用，但这不能证明完整请求路径是安全的。

第一轮测试应使用生产环境中的 URL 运行时。下面的 Node 脚本会测试 WHATWG URL 解析器，并报告审批网关应进行比较的字段。它会刻意输出 JSON，让 CI 中的差异更容易阅读。

```js
const cases = [
  "https://example.test/",
  "https://example.test./",
  "https://münich.example.test/",
  "https://xn\\u002d\\u002dexample-ascii-label.test/",
  "https://pаypal.example.test/",
  "https://api\\u200d.example.test/",
  "https://example.test@attacker.test/",
  "https://example.test:8443/",
  "https://127.0.0.1./"
];

for (const raw of cases) {
  try {
    const u = new URL(raw);
    console.log(JSON.stringify({
      raw,
      href: u.href,
      protocol: u.protocol,
      hostname: u.hostname,
      host: u.host,
      port: u.port,
      username: u.username,
      passwordPresent: u.password.length \u003e 0
    }));
  } catch (error) {
    console.log(JSON.stringify({ raw, rejected: error.message }));
  }
}
```

预期输出的形状比某个放之四海而皆准的具体字符串更重要，因为运行时会变化，主机解析规则也可能因平台而异。每个被接受的案例都应产生一个唯一的规范主机名，并传递到后续所有阶段。每个被拒绝的案例都应证明网关既没有附加凭据，也没有发送网络请求。

应根据团队实际遇到的情况扩充语料，包括实际使用的文字系统中的标签、类似 Unicode 点的字符、主机中的百分号编码、开头的组合标记、方括号和 IPv6 形式、大写 A-label、格式错误的 A-label、空标签、本地域名和重定向。将错误报告中的案例加入测试。测试套件真正有用，是因为里面包含那些让工程师对着日志文件抓狂的字符串，而不是包含十种 `example.com` 的变体。

WHATWG URL Standard 是测试 Web URL 行为的有价值基线，因为它规定了主机解析、主机中的百分号编码失败、Unicode 到 ASCII 的处理以及 IPv4 边界情况。但这并不意味着每个 HTTP 栈、DNS 库或界面组件都具有相同的行为。应让测试语料经过实际使用的完整技术栈。

## 重定向需要新的 authority 决策

第一次审批并不授权请求之后可能访问的所有主机。重定向处理通常位于显示提示的应用代码之下，因此很容易在这里意外地继续转发凭据。

假设代理请求 `https://build.example.test/artifact`。用户批准了用于该主机的部署 token。响应将请求重定向到 `https://downloads.example.test/file`，或者更糟，重定向到一个相似的 Unicode 主机名。如果客户端自动跟随重定向并保留 Authorization 标头，网关就绕过了审批边界。

使用一条简单规则：如果 HTTP 重定向改变了 scheme、规范主机名或有效端口，就使之前的凭据授权失效。如果操作本身允许，网关可以在不带凭据的情况下跟随重定向，但必须在新 authority 处停止注入凭据。应根据新解析的 URL 显示一张新的审批卡。

还要区分 origin 变化和路径变化。在相同的规范 scheme、主机和端口内进行重定向，如果操作的路径规则允许，可能可以保留授权。不要把它简化为文本前缀比较。`https://api.example.test.evil.test/` 以一个令人安心的字符串开头，但它属于不同的主机。

对于 bearer token，默认在每次跨 authority 重定向时删除 Authorization。对于客户端证书和 SSH 类型的凭据，传输层可能在重定向发生前就选择了身份，因此网关需要在连接建立时采用等效规则。原则不变：授权绑定的是解析后的 authority，而不是代理最初的意图。

## 凭据选择需要精确的边界

持有多个 API 密钥的网关必须决定哪个密钥可以发送到某个主机。这不是界面问题，而是显示错误变成网络操作的地方。

可以将凭据绑定保存为结构化数据，例如：

```json
{
  "scheme": "https",
  "host_ascii": "api.example.test",
  "port": 443,
  "allow_subdomains": false,
  "require_per_call_approval": true
}
```

避免使用 `host.endsWith("example.test")` 这样的规则。它会接受 `notexample.test`，而即使检查前置点的粗糙变体，也可能错误处理 Unicode 规范化或末尾根点。如果支持子域，应拆分规范 ASCII 标签，并从右到左比较标签。只有在绑定明确允许子域时，`api.example.test` 才能匹配父域 `example.test`。父域绝不能反向匹配子域。

应将 IP 字面量与注册名称分开处理。不要对 IP 进行反向解析后再套用主机名凭据规则。DNS 名称可能发生变化，反向 DNS 不能证明某个 IP 被授权用于 API 凭据。同样，不要解析一个主机名，然后批准它返回的每个地址。凭据绑定的是用于 TLS 和 HTTP authority 的主机名，而连接控制可以另外限制私有、回环、链路本地或其他不允许的地址。

网关还应区分凭据绑定和审批。绑定回答的是“这个凭据是否有可能在这里使用？”审批回答的是“某个人是否授权这个代理进程将它用于这次请求或会话？”混淆这两个问题，就会让允许列表变成无人值守的签名工具。

## 审计记录必须保留审核者看到的内容

当某次审批后来显得不正确时，操作人员需要回答三个问题：代理发送了什么，网关执行的是哪个规范 authority，以及审核者看到的确切文本是什么？单个渲染后的 URL 无法回答这三个问题。

应分别记录以下字段：

- 原始 URL，或其安全转义形式；
- 解析后的 scheme、规范 ASCII 主机名、有效端口和路径；
- 经过验证的 Unicode 主机名显示形式，如有；
- 主机名风险标志，例如末尾根点、混合脚本结果、与受保护名称的混淆匹配，以及不可见字符拒绝；
- 审批决定、凭据标识，以及网关是否附加了凭据。

在客户端开始请求之前，让审批记录不可变。如果客户端随后跟随重定向，就为它创建一条关联的子记录，其中包含自己的解析 authority 和决定。只写着“已批准的请求成功”的日志行不够，因为真正重要的事实可能是第一个主机返回了指向第二个主机的 Location 标头。

Sallyport 将代理运行放入 Sessions 日志、将单次调用放入 Activity 日志，这种分离很适合保存证据：会话告诉你哪个进程获得了权限，而调用记录告诉你权限被用于哪个目的地。它的哈希链式审计日志可以离线验证历史，但有用的字段仍必须在操作发生前采集。防篡改的空字段依然是空字段。

## 让可疑主机名增加代理的成本，而不是让审核者困惑

最安全的审批体验不会要求人们在两秒钟内成为 Unicode 专家。它会让代理在遇到高风险形式时需要更多权限，同时给审核者足够的证据来作出明确决定。

可以使用以下行为矩阵：

| 主机条件 | 网关操作 |
| --- | --- |
| 具有精确凭据绑定的普通规范 ASCII 主机名 | 采用正常的会话级或逐次调用行为 |
| 有效的国际化主机名 | 显示 ASCII 和 Unicode 两种形式，然后应用正常绑定规则 |
| 混合脚本或与受保护名称混淆 | 要求逐次审批，并在需要时显示码点详情 |
| 末尾根点 | 保留并显示该点，只通过已记录的规范规则进行比较 |
| 无效 A-label、不可见控制字符、不支持的分隔符或解析器不一致 | 在任何凭据或连接尝试前拒绝 |
| 重定向到改变后的 authority | 停止转发凭据并请求新的审批 |

不要把警告卡做得过于戏剧化。满屏红色文字只会教人点击跳过。用直白的语言说明原因：“这个主机名混用了拉丁字母和西里尔字母”，或“这个主机名包含不可见 Unicode 字符”。然后显示规范 ASCII 主机名、请求的凭据和具体操作。人们审批的是操作，不是讲座。

首先应添加这样一个测试：对普通读者来说，它看起来像某个受保护主机，但解析后却属于另一个 authority。让它经过完整的代理 shim、审批界面、凭据选择器、HTTP 库、重定向代码和审计日志记录器。如果任何阶段产生了不同的主机名字符串，却没有因此拒绝请求，网关仍然存在两个事实来源。在添加另一个策略开关之前，先修复这一点。
