# API 审批卡片如何显示真实目标

审核者无法仅凭一个看起来像目标地址的字符串批准 HTTP 操作。卡片必须显示传输层实际使用的请求目标，也就是凭据离开设备前完成解析后的结果。

这听起来很明显，但代理可能提交 `HTTPS://API.EXAMPLE.TEST:443/%76%31/../admin`，客户端接受了它，而人看到的却只是 `api.example.test` 这样的缩短标签。这样的卡片并没有让人做出知情同意，而是要求人相信一个可能与 HTTP 协议栈不一致的渲染器。

解决办法不是让审核者学习 URL 语法的每个细节，而是生成一份统一的规范化请求描述，用清楚的方式呈现，并确保执行器使用同一份描述。协议、主机、有效端口、方法和路径是最低要求。查询值、重定向、调用方控制的标头和请求体通常也应显示在旁边，因为它们同样可能大幅改变操作。

## 审批卡片如何赢得一个“批准”

只有当审批卡片用审核者能够核对的方式说明网络操作时，它才真正值得获得批准。单独的主机名只是身份声明，不是请求描述。`POST https://billing.example.test/v1/invoices/481/refund` 能告诉人们的信息，远多于 `billing API` 或 `example.test`。

把操作行放在最前面，并保持固定顺序：

```text
POST https://billing.example.test/v1/invoices/481/refund
```

然后在下面紧接着放置会改变含义的细节：

```text
Authorization: injected from vault entry "billing-production"
Query: dry_run=false
Body: JSON, 214 bytes, sha256: 7b1f...c0a9
```

不要把凭据值、授权占位符或友好的集成名称放在目标地址的位置。它们可以帮助审核者识别上下文，却不能证明目标在哪里。

方法必须出现在第一行，因为它会改变同一路径对应的后果。`GET /exports/481` 和 `DELETE /exports/481` 不是同一个操作的两种写法，而是两个不同操作，绝不能合并成一个审批标签。

路径也必须出现在第一行，因为 API 路由通常就在路径中。只显示 `api.example.test`，就迫使审核者猜测代理是在读取配置文件、创建访问令牌，还是删除项目。这是在浪费一次审批中断。

## 规范化是显示契约，不是权限匹配

规范化回答的是：“解析后的请求应该怎样显示给人看？”它不回答：“哪些目标地址被允许？”团队经常把这两项工作混在一起，最后得到一个披着友好格式外衣的脆弱允许列表。

对于审批卡片，应在解析后创建结构化目标记录：

```json
{
  "method": "POST",
  "scheme": "https",
  "host": "api.example.test",
  "port": 443,
  "port_display": null,
  "path": "/v1/invoices/481/refund",
  "query": "dry_run=false",
  "raw_url": "HTTPS://API.EXAMPLE.TEST:443/v1/invoices/481/refund?dry_run=false"
}
```

执行器应使用同一组结构化字段，或使用由这些字段序列化得到的 URL。不要为了卡片解析一次，之后又把原始字符串交给另一个库。审批界面沦为表演，通常就是从这种分裂开始的。

RFC 3986 区分了几类安全的规范化操作。它把协议和主机视为不区分大小写，建议百分号转义中的十六进制数字使用大写，并说明如何移除点段。它还提醒我们，代码必须先解析 URI 组件，再解码百分号编码的字节，因为错误的解码时机可能把数据变成分隔符。这是实际的工程建议，不是规范中的琐事。

同时保留一份代理原始输入记录。它应进入活动记录，在有人需要调查异常请求时也应出现在详情视图中，但不应和规范化操作行争夺审核者的注意力。

一个简单的规则是：卡片显示规范化描述，日志同时保存描述和输入，授权决策则不能用其中任何一项替代明确的范围规则。它们是不同的数据产品。

## 先解析，并拒绝传输层无法解释的输入

一旦人批准了 URL 的显示结果，URL 解析器就成了安全边界的一部分。为支持的 URL 协议选定一种解析行为，并让它同时成为渲染和执行的事实来源。

对于普通 HTTP API，应在存在不确定性时拒绝输入，而不是自作聪明地帮忙处理。相对引用在获得明确的基础 URL 前没有主机。片段不会出现在 HTTP 请求中，不应被显示成仿佛会影响服务器。`https://alice@api.example.test/` 这样的用户信息在 API 审批流程中几乎总是会造成误导，应拒绝，而不是悄悄隐藏。

使用有明确失败点的解析流程：

1. 出站 API 通道只接受绝对的 `http` 或 `https` URL。
2. 使用操作执行器选定的 URL 实现解析它。
3. 拒绝用户信息、缺少主机、格式错误的端口值、不支持的协议和无效的百分号转义。
4. 根据解析后的组件和获准的标头构造实际请求。
5. 根据这些组件渲染卡片，然后提交完全相同的请求。

不要用字符串拆分手写这套逻辑。每个位置中的第一个 `@`、`:`、`/`、`?` 和 `#` 并不总是含义相同。IPv6 权威地址需要方括号。方括号后的冒号可以引入端口，而方括号内部的冒号属于地址本身。解析器知道这个区别，简短的正则表达式通常不知道。

WHATWG URL Standard 定义了 URL、主机、域名和 IP 地址的解析与序列化行为。其安全指导还指出，双向文本可能让人混淆主机和路径，并建议在这种情况下单独渲染主机。安全产品应采取更严格的做法：在每张卡片中都让权威地址和路径在视觉上彼此独立，而不只是针对异常字符串。

如果操作层有自定义客户端，应使用测试语料证明它与解析器的行为一致。不要假设两个成熟库对空格、反斜杠、Unicode 主机名或异常数字 IP 形式有相同的容忍度。一致性是需要测试的属性。

## 解码百分号转义，但不要改变路由

百分号编码会制造最危险的一类误导性 URL：随意解码后看起来无害，实际却会被路由器、代理或上游服务解释成另一回事。

考虑以下路径：

```text
/v1/projects/%2E%2E/admin
/v1/projects/%252E%252E/admin
/v1/files/report%2Ffinal
```

第一条包含百分号编码的点。第二条包含一个编码后的百分号，后面跟着 `2E`，这和第一条输入不同。第三条在一个路径段中包含编码后的斜杠。如果显示层反复解码这三条路径，直到得到可读标点，就可能显示出客户端根本没有发送的路径结构。

RFC 3986 给出了一种范围很窄的安全情况：规范化时可以解码未保留字符的百分号转义。未保留字符包括字母、数字、连字符、句点、下划线和波浪号。`/`、`?`、`#`、`@` 和 `:` 等保留字符，如果解码会改变组件边界或分隔符，就必须保持编码状态。RFC 还规定，实现不能对同一个字符串重复编码或解码。

因此可以采用这样的显示规则：

```text
Raw path:       /v1/%75sers/alice%7Eops/report%2Ffinal
Card path:      /v1/users/alice~ops/report%2Ffinal
Wire path:      /v1/users/alice~ops/report%2Ffinal
```

卡片把 `%75` 和 `%7E` 显示为可读字符，因为它们代表未保留字符；同时保留 `%2F`，因为斜杠会改变路径段结构。线路形式和卡片形式可以存在无害差异，但必须保留相同的路由含义。

不要在不加区分地解码路径后再移除点段。应按其编码结构解析路径，执行明确规定的规范化流程，并保留那些把保留字符作为数据的转义。如果下游服务采用不同的解码顺序，那是一个应通过测试暴露出来的兼容性和安全问题，不是让卡片自行猜测的理由。

## 主机名不是完整的权威地址

HTTP 目标的权威地址包括主机，以及在非默认情况下的端口。省略端口会让审批卡片通过遗漏来误导审核者。

以下目标应视为不同内容：

```text
https://api.example.test/v1/keys
https://api.example.test:8443/v1/keys
http://api.example.test/v1/keys
```

第一条通常使用 443 端口，第二条使用 8443 端口，第三条使用不同的协议，通常使用 80 端口。审核者可能批准通过 HTTPS 调用生产 API，却拒绝发送到自定义端口测试监听器的请求。卡片必须让他们能够做出这个决定。

将协议和主机名统一转成小写。只有在端口是解析后协议的默认端口时才隐藏它：`http` 对应 80，`https` 对应 443。不要因为 DNS 记录碰巧指向熟悉的位置，就隐藏端口。

国际化域名同样需要谨慎处理。对人友好的 Unicode 形式可能更易读，而 DNS 在线路上使用 ASCII 标签。如果显示 Unicode 形式，也要同时在详情中显示 ASCII 形式，并使用采用明确主机处理算法的解析器。不要自行编写 punycode 转换，也不要比较显示字符串来判断等价性。

IP 字面量需要单独处理。IPv6 地址要显示方括号，保留非默认端口，并标明这是 IP 字面量。`https://[2001:db8::9]/v1/keys` 不应仅仅因为代理在备注字段中提供了一个好听的别名，就看起来像某个命名的生产服务。

别名会带来另一个问题。`api.internal`、`api` 和 `10.0.0.9` 今天可能指向同一台服务器，DNS 变化后却可能分开。不要为了审批而悄悄把一个改写成另一个。显示客户端请求的解析后权威地址。如果系统会在建立连接前解析 DNS，则把选中的地址作为连接上下文显示，并记录到审计轨迹中。HTTP 请求命名的仍然是权威地址。

HTTP 明确区分了这两者。RFC 9110 规定，`Host` 字段提供目标 URI 中的主机和端口信息；HTTP/2 和 HTTP/3 可以在 `:authority` 中传递这些信息。RFC 9113 规定，中间件从 HTTP/2 的 authority 生成 `Host` 时，必须使用 `:authority`，除非它改变了请求目标。因此，卡片必须把调用方提供的 authority 字段视为路由材料，而不是装饰性元数据。

## 方法和路径需要自己的视觉权重

把 HTTP 方法、权威地址和路径放在一起，因为审核者会把操作当成一句话来阅读。然后让方法和危险的路径段具有足够的视觉对比，避免快速浏览时被一长串 URL 夷平。

这种布局有效，是因为各部分顺序稳定：

```text
DELETE
https://api.example.test/v1/projects/acme/production
```

对于会改变对象的请求，应在可见路径中包含对象标识符。为了适应卡片而把 `/v1/projects/acme/production` 的末尾截掉，是本末倒置。如果空间有限，应优先截断较长的查询值或请求体预览，绝不要截断标识目标的最后一个路径段。

路径中的大小写必须保留。RFC 3986 规定，除协议和主机外，通用 URI 语法中的组件都区分大小写，除非协议另有规定。许多框架会区分大小写地路由，即使某个具体 API 恰好不区分。把 `/Admin/DeleteUser` 改成 `/admin/deleteuser`，等于描述了一个从未发出的请求。

请求路径看起来无害，但查询参数也可能改变操作：

```text
POST https://api.example.test/v1/invoices/481/refund?dry_run=false
POST https://api.example.test/v1/invoices/481/refund?dry_run=true
```

当参数会改变范围、行为或身份时，应在操作行下方显示简短的查询摘要。对于非结构化或很长的查询，在可展开的详情区域显示完整的编码查询，并在主卡片中显示经过脱敏的解码摘要。不要因为卡片想要友好，就把令牌解码成可读的秘密。

请求体可能比路径更重要。批准 `PATCH /v1/users/alice` 并不能说明什么，因为请求体可能授予管理员角色。至少显示内容类型、字节长度和稳定摘要。对于 JSON 等结构化格式，可以预览少量发生变化的字段，但前提是预览来自将要在线路上传输的同一组字节。签名或哈希使用一组字节，显示时又重新序列化对象，会造成与 URL 重解析相同的分裂问题。

## 标头和重定向可能改变请求去向

如果其他请求字段可以引导连接，那么一个规范化 URL 并不能挽救审批流程。卡片必须约束这些字段，或显示它们的影响。

先看 `Host` 和 `:authority`。HTTP 客户端通常根据目标 URL 生成它们。如果操作接口允许调用方覆盖，应拒绝这种覆盖，除非传输层有记录在案的理由支持它。如果确实支持，审批行需要同时显示连接目标和请求的 authority，让人可以比较两者。

代理配置也应同样处理。代理会改变直接对端，但不一定改变源目标。不要在卡片中用代理地址替换源地址，应把源地址显示为正在审批的操作，把代理显示为传输上下文。如果代理可以改写目标字段，就应将它视为执行器组件，配备测试、日志和独立的信任决定。

当重定向目标发生变化时，重定向就是新的操作。根据某些重定向行为，`POST` 可能变成 `GET`，也可能被重新发送到新的权威地址。原始审批只应覆盖原始目标。跟随重定向前，应解析 `Location` 值，构造下一条拟议请求，比较其协议、权威地址、方法、路径、查询和请求体行为，只要有重要变化就再次询问。

不要采用“在本次运行期间批准某个站点，并把它下面的所有重定向都视为安全”这种常见捷径。它看似减少了中断，却把 URL 解析和重定向策略变成了不可见的权限扩张。如果运行确实需要更宽泛的权限，应在审批文字中明确写出范围，而不是让重定向偷偷扩大权限。

## 把原始输入放进记录，而不是决策行

审计轨迹需要足够详细，才能分别回答两个问题：代理请求了什么，执行器尝试了什么。一个 URL 字符串并不总能同时回答这两个问题。

准确记录收到的原始 URL 字符串，但要遵守秘密脱敏规则。单独记录规范化目标。如果执行器进行了地址解析，还要记录最终连接权威地址和解析出的地址。对于 HTTP/2 或 HTTP/3，记录有效的 `:authority`；对于 HTTP/1.1，记录有效的 `Host` 值。把每个重定向跳转记录成单独的尝试请求，而不是在第一次调用的脚注中一笔带过。

日志条目可以采用这样的形式：

```json
{
  "request_id": "req_01J...",
  "agent_input_url": "HTTPS://API.EXAMPLE.TEST:443/v1/%75sers/alice%7Eops",
  "approved_target": "GET https://api.example.test/v1/users/alice~ops",
  "effective_authority": "api.example.test",
  "effective_port": 443,
  "connection_ip": "203.0.113.42",
  "result": "200"
}
```

示例使用了文档专用地址空间中的连接 IP。在真实日志中，应在数据进入任何长期记录前保护查询秘密、授权材料和敏感请求体。摘要可以把批准的内容与执行的内容关联起来，无需在每个界面复制私有载荷。

区分原始输入和规范化目标，在调查时很有价值。如果卡片显示的是普通路径，而原始输入包含多层编码，就可以判断是解析器、渲染器还是 HTTP 客户端出现了分歧。如果只保存美化后的 URL，就丢失了查找缺陷所需的证据。

Sallyport 的 Activity journal 和 Sessions journal 都从一份加密、带哈希链的审计日志投影而来。因此，目标表示应作为操作记录的一部分只写入一次，而不是之后从界面字符串中重新构造。只有当记录的操作字段在执行时真实可靠时，离线的 `sp audit verify` 检查才有用。

## 测试普通 URL 无法暴露的分歧

真正重要的单元测试，不是十个普通的 `https://api.example.test/v1/users` 示例，而是原始字符串、卡片渲染器和传输库可能产生分歧的情况。

构建一个表驱动的测试语料，断言解析后的字段、可见目标、线路目标和决策。至少包含以下类别：

- 协议和主机大小写变化，以及默认和非默认端口；
- 点段，以及未保留字符和保留字符的百分号转义；
- 编码后的百分号、编码后的斜杠和格式错误的转义序列；
- IPv6 字面量、Unicode 主机输入，以及必须拒绝的用户信息；
- 会改变操作行为的查询值，以及指向另一个权威地址的重定向目标。

测试用例应明确写出预期表示：

```json
{
  "input": "HTTPS://API.EXAMPLE.TEST:443/v1/%75sers/alice%7Eops?role=viewer",
  "decision": "approve",
  "card": "GET https://api.example.test/v1/users/alice~ops?role=viewer",
  "wire_url": "https://api.example.test/v1/users/alice~ops?role=viewer"
}
```

然后添加必须在审批前失败的否定用例：

```json
{
  "input": "https://alice@api.example.test/v1/users",
  "decision": "reject",
  "reason": "userinfo is not supported for outbound API actions"
}
```

使用真正建立连接的客户端代码运行整套语料。只测试解析器的测试套件可以发现渲染错误，却会漏掉传输行为，例如库把空路径规范化、注入默认 authority，或应用自己的重定向规则。

最后，按照审核者实际使用卡片的方式测试界面。确认在普通窗口尺寸下，完整的方法、主机、存在时的端口以及最后一个路径段都保持可见。如果一个请求会删除生产数据，却必须悬停、展开或滚动后才能发现，那么安全文案就失效了。重要差异应在审批按钮获得焦点前就清楚呈现。

人工审批可以成为强有力的控制措施，但它的强度取决于呈现在人面前的请求描述。根据解析后的组件构建描述，使用这些组件执行请求，保留原始输入供日后审查，并拒绝含糊不清的内容，而不是用装饰掩盖它。
