阅读需 8 分钟

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

Unicode 主机名提示需要在 HTTP 网关添加凭据前确定唯一的规范目的地。测试 Punycode、不同文字、点、不可见字符和重定向。

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 文本,让审核者看到一套传输层实际上从未表达过的说法。

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

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 主机名几乎一模一样。例如,一个标签可以包含看起来像拉丁字母 aceopx 的西里尔小写字母。审核者快速浏览审批卡时可能会漏掉替换,尤其是名称中有意义的部分很短时。

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 文本。良好的活动记录可以包含这样的文本:

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

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

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

保留每次调用的证据
Activity 日志会记录每次独立调用,并使用同一套加密、哈希链式审计历史。

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

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

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

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 中的差异更容易阅读。

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://[email protected]/",
  "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 决策

在执行时注入 API 凭据
Sallyport 自行注入 bearer、basic 或自定义标头凭据,因此代理永远不会收到明文值。

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

假设代理请求 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 密钥的网关必须决定哪个密钥可以发送到某个主机。这不是界面问题,而是显示错误变成网络操作的地方。

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

{
  "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 的主机名,而连接控制可以另外限制私有、回环、链路本地或其他不允许的地址。

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

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

让 HTTP 凭据留在代理之外
Sallyport 将 API 密钥保存在加密保险库中,并在不向代理暴露凭据的情况下执行 HTTP 调用。

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

应分别记录以下字段:

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

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

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

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

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

可以使用以下行为矩阵:

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

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

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

常见问题

为什么审批提示中的 Unicode 域名有风险?

如果审批界面和 HTTP 客户端没有使用同一个经过解析和规范化的目的地,就可能批准发送到另一个主机的请求。风险不在于 Unicode 本身不安全,而在于把面向人的显示形式当成权威,却让另一个组件决定凭据实际发送到哪里。

在审批对话框中显示 Punycode 安全吗?

Punycode 是国际化域名标签的 ASCII 编码,不是安全结论。某个标签即使能正确解码,也可能让审批人感到困惑。因此,审批界面应把规范 ASCII 主机名作为权威信息,同时只把 Unicode 形式作为辅助上下文。

末尾的点会改变 HTTP 主机名吗?

末尾的点通常表示 DNS 根,并可将主机名标记为绝对 DNS 名称。HTTP 栈可能保留它、规范化它、拒绝它,也可能在连接池和证书检查中以不同方式处理它。在准确的解析器和传输层证明两者等价之前,应把它当作有意义的输入。

是否应该阻止所有混合脚本主机名?

不应该。混合脚本主机名可能是使用多种文字的语言中的合法名称,而只使用一种文字的主机名也可能仿冒 ASCII 名称。混合脚本检测是有用的警告信号,不应直接成为权限决定。

主机名中的不可见字符是什么?

不可见字符是可能没有可见字形、会影响字符连接或文字方向,或者在特定字体中消失的 Unicode 码点。它们会给安全提示带来风险,因为两个字符串看起来可能相同,但在解析、显示、日志记录或比较代码中仍是不同的输入。

代理的 HTTP 请求审批提示应该显示什么?

审批应针对经过解析的 scheme、规范主机名、端口、凭据标识、HTTP 方法以及明确无歧义的路径摘要。不要只审批原始 URL 字符串,也不要允许重定向或重试把该审批带到新的 authority。

代理能解决 Unicode 主机名审批问题吗?

只有当代理与注入凭据的客户端共同参与同一套规范化和授权边界时,代理才可能解决 Unicode 主机名审批问题。代理日志可以提供有用证据,但无法修复基于另一种 URL 解析结果做出的审批决定。

我可以只允许列表中的域名吗?

对于高价值凭据,允许列表是必要的,但仅靠字符串匹配还不够。应保存规范 authority 记录,比较主机边界而不是后缀文本,在端口有意义时保留端口,并在请求超出已保存的 authority 时强制重新审批。

哪些组件需要进行 Unicode 主机名测试?

应测试每个能够解析、显示、解析地址、建立连接或重定向 URL 的运行时。通常包括代理 shim、网关、HTTP 库、用于审批的浏览器视图、DNS 解析路径以及审计日志渲染器。

如果目的地可疑,仅做秘密脱敏够吗?

不能。脱敏只能保护日志记录之后或记录过程中的秘密,无法阻止凭据被发送到审批人误解的目的地。网关在附加 Authorization 标头或客户端凭据之前,必须先确定目的地身份。

Sallyport

Sallyport 替你的 AI 智能体执行 API 调用和 SSH 命令。密钥留在你 Mac 上的本地密钥库里;每次运行由你批准,每个操作都落入一份密封的审计日志。

© 2026 Sallyport · 依据 Apache-2.0 开源 · Oleg Sotnikov