# URL 用户信息能隐藏你的 API 目标吗？

接受任意 URL 的代理，可能被指向其操作者并不想访问的地方。URL 用户信息让这个错误更容易发生，因为它会在 `@` 符号前放置一个熟悉的主机名，而真正的目标位于符号之后。如果你的审批界面、允许列表或审计视图把整个字符串当成类似主机名的标签，某个请求看起来已经获准，却仍然可能发往另一台服务器。

除非有明确记录的狭窄兼容性理由，否则应将用户信息视为出站 API 操作中的不允许输入。在边界处只解析一次 URL，在凭据进入请求前拒绝非空的用户信息字段，之后的每个决策都基于解析后的字段。这不是什么奇特的 URL 技巧，而是普通语法遇上了一个要求人们在极短时间内读懂过多标点的审核界面。

## 主机名位于最后一个 @ 符号之后

在绝对 HTTP URL 中，authority 位于 `//` 和下一个 `/`、`?` 或 `#` 之间。RFC 3986 将 authority 描述为可选的用户信息、随后是 `@`、主机以及可选端口。因此，`//` 后面最先出现的文本并不一定是主机。

看下面这个请求：

```text
https://api.example.com@collector.invalid/v1/charges
```

快速扫读开头的人可能会注意到 `api.example.com`，然后就停在那里。符合规范的 URL 解析器会这样拆分：

```text
scheme:   https
userinfo: api.example.com
host:     collector.invalid
port:     443
path:     /v1/charges
```

TCP 连接和 TLS 主机名验证使用的是 `collector.invalid`。`@` 前面的字符串并不标识远程服务器。它属于用户信息，是 URL 语法中曾经用于名称和密码的旧式组成部分。

同样的问题也会以更像常规地址的形式出现：

```text
https://billing.example.com:wrong@evil.invalid/invoices
```

`@` 前面的所有内容仍然属于用户信息，包括冒号及其后的文本。远程主机依旧是 `evil.invalid`。审核者不能从原始 URL 中第一个看起来像主机名的字符串，安全地推断目标。

不要试图靠培训审核者注意这个字符来解决问题。人们会在事件复盘时、漫长工作日结束时，以及代理生成大量操作请求时犯这种错误。依赖完美视觉解析的控制措施本身就很脆弱。

浏览器和许多运行时使用的 URL Standard，在 HTTPS 这类特殊协议上也会产生相同的实际结果：解析出的用户名和密码字段与主机名分开。对于格式错误的输入和转义，具体解析行为可能不同，这也是应该使用实际发送请求的运行时进行解析的原因。不要用一个库验证，再交给另一个接受不同字符串集合的库执行。

有一条实用规则很简单：只有解析后的主机名才能决定请求是否允许发往某处。原始 URL 文本只是证据，不是权限依据。

## 用户信息首先制造审核问题，然后才可能制造网络问题

网络客户端通常知道自己要去哪里。问题更早发生在某个人或某个控制措施批准了错误的目标表示形式时。

典型的代理流程中，有多处可能让原始文本直接流出：工具调用参数、审批卡片、会话日志、错误消息和通知。如果其中一个视图因为提取了 `@` 前的文本而显示 `正在调用 api.example.com`，它就向操作者传达了错误信息。如果另一个视图记录了完整字符串，却在固定宽度处截断，真正的主机可能完全消失。

即使代理没有目标服务器的凭据，这也很重要。出站请求可能携带用户数据、签名请求体、由宽泛规则选出的 bearer token，或者只是访问本不该接收代理流量的内部地址。凭据问题因为具体而受到更多关注，目标完整性同样需要严格管理。

人们经常混淆 URL 显示安全和 URL 传输安全。在 HTML 视图中转义 `@` 可能让页面不那么令人困惑，但它不会决定 HTTP 客户端连接到哪里。反过来，解析器可能正确地完成网络调用，但设计糟糕的审批视图仍会诱导人们批准错误的主机。你需要正确的传输决策，也需要诚实的显示方式。

不要在存储的请求中把 `@` 替换成无害字符后继续执行。这样会隐藏导致拒绝的输入，也会让后续调查更困难。应将原始字符串保留为原始输入，将请求标记为已拒绝，并记录解析后的原因，同时不要把嵌入式凭据写进任何易读的日志。

显示界面应先突出独立的目标字段，例如 `Host: collector.invalid`，再将原始 URL 放在下面。这样的顺序把标点谜题变成了直接陈述，也为审核者提供了一个稳定字段，可与请求的凭据或预期集成进行比较。

## 拒绝用户信息比修复它更安全

对于代理操作网关，清晰的默认规则是：只要解析出的用户名或密码字段非空，就拒绝所有出站 HTTP URL。大多数 API 集成已经通过请求头发送凭据，或使用凭据注入器。支持 URL 用户信息会增加攻击面，却不能解决普通 API 的需求。

验证顺序很重要。先解析原始字符串。然后在主机允许列表、审批提示、重定向、DNS 解析或凭据选择之前，拒绝格式错误的 URL、不支持的协议和用户信息。这样可以避免先把请求显示成符合条件，之后又丢弃其中一部分。

下面的伪代码表达了这条规则：

```text
u = parse_absolute_url(raw_url)

if u.scheme not in {"https", "http"}:
    deny("unsupported scheme")

if u.username != "" or u.password != "":
    deny("URL userinfo is not accepted")

host = normalize_hostname(u.hostname)
if host == "":
    deny("missing hostname")

if not destination_is_allowed(u.scheme, host, u.port):
    deny("destination is not allowed")

send(u)
```

解析器必须返回结构化字段。在字符串中拆分 `@`、去掉前缀，或搜索主机名子串，都会在普通变体面前失效。authority 中可以包含多个原始 `@`。解析器会判断哪个分隔符具有语法意义，以及前面的字符是否属于用户信息。它还会比手写字符串逻辑更一致地处理方括号括起的 IPv6 字面量、显式端口、百分号编码和空组件。

拒绝用户信息会给调用方一个明确的修正方向：使用 `https://api.example.com/path`，再通过指定的凭据路径提供 HTTP 身份验证。调用方不必猜测你是否会把 `https://name@host` 静默改成 `https://host`。

有一种兼容性场景值得说明。一些旧 URL 会将 Basic 认证嵌入为 `https://name:secret@host/path`。如果迁移必须处理这类 URL，应在一次性导入路径中提取凭据并存入受保护的存储，确认解析出的主机，并在策略允许时从导入记录中删除源字符串。不要让运行时操作 API 永久接受它们。临时兼容代码很容易变成永久攻击面。

## 主机允许列表需要解析后的标签，而不是看起来友好的字符串

目标允许列表应比较规范化后的解析主机名，而不是在原始 URL 上进行子串搜索。规则 `raw_url.includes("api.example.com")` 会同时接受 `https://api.example.com@evil.invalid` 和 `https://api.example.com.evil.invalid`。这两个请求都不是发往 `api.example.com`。

精确匹配主机是最不容易产生意外的规则。如果集成只需要 `api.example.com`，就允许这个名称，拒绝其他所有主机名。如果确实需要子域名，应比较 DNS 标签：允许 `example.com` 以及以 `.example.com` 结尾的名称，但拒绝 `badexample.com` 和 `example.com.evil.invalid`。

清晰的实现形式如下：

```text
function allowedHost(host, root) {
  const h = host.toLowerCase().replace(/\.$/, "")
  const r = root.toLowerCase().replace(/\.$/, "")
  return h === r || h.endsWith("." + r)
}
```

这段代码假设 URL 解析器已经提供主机名，调用方也已经拒绝用户信息。它不应接收完整 URL。分离这些职责，可以防止后续调用方传入包含端口、用户名或 `@` 符号的 authority 字符串。

国际化域名也需要明确处理。浏览器通常使用 IDNA 处理，将主机名序列化为 ASCII，而用户可能看到 Unicode 文本。比较时使用请求客户端采用的同一种规范形式，若两者不同，则同时显示规范主机和易读形式。不要因为两个字符串在比例字体中看起来相似，就断言它们标识同一个域名。

IP 地址需要单独制定规则。主机名允许列表不会自动让 IP 字面量变得安全，审批后进行 DNS 解析也可能改变主机名最终连接的地址。如果你的威胁模型包括访问本地服务，就应明确决定是否允许私有地址、回环地址、链路本地地址和 IPv6 本地地址。拒绝用户信息是必要措施，但它本身不能解决服务端请求伪造问题。

端口同样有意义。`https://api.example.com:8443` 可能是有效的合作伙伴端点，也可能是意外暴露的管理服务。如果集成限制了端口，应记录生效端口，并在审批决策中将其包括进去。仅有主机标签并不能描述完整的网络目标。

## 重定向必须经过同一套检查

初始 URL 获准，并不意味着所有重定向目标都获准。HTTP 重定向是远程服务器提供的新目标指令，代理网关应在每次跟随前解析并授权。

假设代理请求 `https://api.example.com/export`。该主机返回一个 302 响应，其中的 location 是：

```text
https://api.example.com@receiver.invalid/download?id=42
```

自动跟随重定向的客户端下一步会连接到 `receiver.invalid`。如果网关只批准了第一个 URL，那么它的允许列表和审批界面就不再描述实际发生的网络操作。

应将重定向作为一个带上限的循环处理，上限根据客户端选择。对于每个 location 值，先根据当前已批准的 URL 解析相对引用，得到绝对 URL，再解析该 URL，应用相同的协议、用户信息、主机、端口和地址规则，最后决定是否继续。记录原始响应以及解析后的重定向目标。

默认不要跨主机转发凭据。HTTP 客户端库对跨主机重定向后是否保留 `Authorization` 请求头的处理各不相同，自定义请求头的行为可能又不同。最安全的网关行为，是将凭据绑定到某个特定的已批准目标，并且只有在重定向目标通过授权后，才构造新的出站请求。从一个供应商拥有的主机重定向到另一个主机可能是预期行为，但它应成为明确规则，而不是库默认行为造成的意外。

请求方法也需要关注。303 响应通常会让后续请求改为 GET，而 307 和 308 会保留方法和请求体。如果代理提交了敏感请求体，保留方法的重定向可能会把它发送到别处。记录每一跳使用的方法，并在操作结果中显示最终目标。

也可以将客户端配置为不跟随重定向。对于范围狭窄的 API 工具，这是合理选择。将重定向响应返回给代理，要求它明确请求下一个目标。这样会产生另一个审批事件，但也为操作者提供了清晰的主机变更评估点。对于广泛的 HTTP 支持，只有当每一跳都经过同一套检查时，自动重定向才是可接受的。

## 必须在目标验证后选择凭据

危险的顺序很容易描述：因为原始 URL 包含熟悉的服务名称，所以先选择凭据，然后解析 URL，最后发送请求。`@` 技巧可以把熟悉的文本变成用户信息，而被选中的秘密就会随请求发送到攻击者控制的主机。

更安全的顺序同样简单。先解析并验证 URL。然后授权其协议、主机、端口和任何重定向状态。只有这样，才查找绑定到该已批准目标的凭据，并将其注入出站请求。不要让秘密进入工具参数、代理记忆或返回值。

这也能解决一个不那么引人注意但很常见的配置错误。与 `api.example.com` 关联的凭据，不应因为 `uploads.example.com` 也位于同一父域名下，就自动发送到后者。不同主机往往有不同的所有权、TLS 终止、日志记录或权限范围。先从精确主机绑定开始，只有在集成明确说明为何需要更宽匹配时，才扩大范围。

相比 URL 用户信息，使用请求头注入凭据更好，因为它将目标与身份验证分开。请求记录可以说明注入了 authorization 请求头，而不保存其值。代理能收到继续工作所需的响应，却不会拿到可重复使用的秘密。

Sallyport 通过将 API 和 SSH 凭据保存在加密保险库中，并在不向代理暴露这些凭据的情况下执行出站操作，来保持这种分离。对于采用这种模式的网关，在查找凭据之前拒绝用户信息，可以弥合操作者预期的目标与实际接收请求的主机之间的差距。

也不要相信代理提供的请求头能说明其目标。`Host` 请求头、HTTP/2 authority、URL 主机、代理配置和 TLS 服务器名称可能以各客户端特有的方式相互作用。网关应拥有连接设置，并从经过验证的解析 URL 中推导这些设置。如果允许自定义请求头，应将它们视为请求内容，而不是允许改写路由的权限。

## 审批卡片应优先显示解析后的目标

审批卡片应该直接回答三个具体问题，不要求阅读者自己还原 URL：哪个进程发起请求、将执行什么操作、哪个主机会接收请求。将解析出的主机名和端口放在独立的目标行中，在附近显示 HTTP 方法和路径。将原始 URL 作为辅助证据放在下面，不要让它成为唯一的目标信号。

对于前面使用的看起来格式异常的请求，一张有用的卡片可以这样写：

```text
Process: signed agent process
Action:  POST /v1/charges
Host:    collector.invalid:443
Result:  blocked because URL userinfo is present
Input:   https://api.example.com@collector.invalid/v1/charges
```

不应把从 authority 左侧提取的 `api.example.com` 显示成徽章，也不应只写 `External HTTP request`，因为那无法为人提供有意义的决策依据。

按会话审批和按调用审批，解决的是不同的人工操作问题。会话审批表示某个正在运行的进程在整个生命周期内可以使用网关。按调用审批表示某个特别敏感的凭据或操作需要重新取得人工决定。两者都不能取代基本的 URL 验证。即使是你信任的进程，也可能受到不可信的问题评论、软件包元数据字段或生成的配置文件操纵。

Sallyport 的决策阶梯会先把锁定的保险库作为绝对停止条件，然后使用会话授权和可选的逐密钥确认。当格式错误的目标在到达审批卡片前就失败时，这种结构最有效，因为操作者不应该被迫判断一段 URL 标点是否改变了端点。

拒绝消息应具体明确。`Userinfo is not allowed in outbound URLs` 能告诉代理开发者要修复什么。`Invalid request` 会导致重试、临时转义，以及要求放宽验证的压力。如果解析器提取出了密码，不要将它原样回显。消息可以指出被禁止的组件，但不应复述其内容。

## 测试应使用具有迷惑性的输入，而不只是正常 URL

验证测试套件需要加入专门欺骗人类读者和简单字符串检查的示例。通过 `https://api.example.com/v1` 几乎不能证明代理可以提交任意文本时的边界是安全的。

可以从下面这些用例开始，并断言解析出的主机、决策和原因：

```text
ALLOW  https://api.example.com/v1                 host=api.example.com
DENY   https://api.example.com@evil.invalid/v1    reason=userinfo
DENY   https://name:secret@api.example.com/v1     reason=userinfo
DENY   https://api.example.com.evil.invalid/v1    reason=host
DENY   https://api.example.com:444/v1             reason=port
DENY   https://[::1]/v1                           reason=address
```

加入一个重定向测试样例。让获准的测试服务器返回一个重定向，其 location 包含用户信息，并断言客户端记录第二跳被阻止，且没有向目标服务器发送请求。这样可以捕捉一种常见错误：初始 URL 验证位于一条代码路径中，而重定向处理藏在库回调里。

有意测试百分号编码，但不要假设所有经过编码的 `@` 都等价。在许多解析器中，路径里的 `%40` 仍然是路径数据，而 authority 中真正的 `@` 才是分隔符。将准确的原始字符串传给生产环境使用的解析器，并断言它返回的字段。重要的不变量不是自制的解码规则，而是非空的解析后用户信息字段绝不能到达发送器。

还要测试日志和审批渲染。安全控制可能正确拒绝了请求，却因为显示界面截断实际主机或暴露解析出的密码文本，留下糟糕的操作记录。为目标行编写快照测试很有价值，因为视觉回归往往来自看似无害的设计改动。

最后，测试被拒绝调用周围的审计链和撤销路径。拒绝应足够清晰，便于调查，但绝不能包含注入的秘密。有用的记录包括：受到适当访问控制保护的原始请求、解析出的协议和主机、决策原因、调用进程身份，以及未发生出站操作这一事实。

## URL 语法不是策略语言

有些团队会通过不断添加例外来应对 URL 边界情况：允许某个供应商使用用户名，为某个环境接受特殊端口，只有请求头看起来符合条件时才信任重定向，并针对每起事件修补字符串匹配器。这种方式看起来很灵活，因为它避免对奇怪请求说不，但最终会产生没人能可靠审核的规则。

让规则保持简洁。出站请求有一个解析后的目标。拒绝用户信息。明确协议、主机、端口和地址类别的允许范围。重定向重新进入同一套检查。只有目标通过后才选择凭据。每个决策都生成一条清楚写出解析后主机的记录。

这条规则会拒绝一些浏览器可能接受的旧 URL。很好。自主代理不需要浏览器地址栏拥有的所有历史功能，它需要的是一个范围明确的接口，让操作、目标和凭据之间不容易混淆。

如果需要修改这个接口，就将例外定义为命名能力，配套测试，并明确其过期决策。不要把例外藏在 URL 清理代码中。第一个恶意输入就会找出「看起来像主机的文本」与客户端实际连接的主机之间的差异。
