# URL 编码故障：在调用前测试代理 API 输入

URL 不是无害的字符串，而是一条结构化指令。分隔符决定主机、路由、参数名和值。如果代理错误地把用户输入与 URL 拼在一起，就可能调用开发者审查过的端点之外的另一个端点。

我见过这样的情况：团队在工具日志中看到 `GET /records/alice`，批准了请求，后来却花了一个下午才发现服务器收到的路由多了一个斜杠，查询参数重复，或者包含已经解码的路径穿越序列。代理不需要什么特别复杂的漏洞利用，只要遇到 `a+b`、`%2F`、`&admin=true`，或非 ASCII 脚本中的名称，就可能触发问题。

URL 编码故障容易被忽略，因为 HTTP 客户端通常会“处理编码”。但它只会根据自身 API 和默认设置完成一部分序列化。它无法判断用户值应该放在路径段、查询值、表单体中，还是根本不应发送。这一决定必须写进操作定义和测试中。

## 决定调用的是请求目标，而不是表面字符串

软件每次解析或重新构建 URL 时，请求的含义都可能改变。代理生成的字符序列只是开始。客户端库可能对它进行规范化，反向代理可能重写它，应用框架则可能在路由匹配或参数绑定前解码它。

RFC 3986 将 URI 分为 scheme、authority、path、query 和 fragment。在路径中，`/` 是分隔符。在查询中，`&` 和 `=` 按常见约定具有特殊含义，尽管 RFC 3986 没有定义统一的查询语法。大多数被笼统称为“编码问题”的故障，都与这一区别有关。

假设某个操作要获取一个项目：

```text
GET https://api.example.test/projects/{project_id}
```

如果 `project_id` 是 `north/ops`，下面两个请求目标并不等价：

```text
/projects/north/ops
/projects/north%2Fops
```

第一个在 `projects` 后有两个路径段，第二个试图在一个路径段中携带字面斜杠。第二种形式能否保留下来，取决于从客户端到应用的完整链路。有些技术栈会在路由匹配前解码 `%2F`，把它变成第一种形式；有些则会直接拒绝。只断言“URL 已经编码”远远不够。

查询字符串也有同样的陷阱。表示搜索字面短语的操作不应直接构造：

```text
/search?q=USER_TEXT
```

然后把 `USER_TEXT` 替换成原始文本。如果输入是 `red&limit=500`，最终目标可能变成：

```text
/search?q=red&limit=500
```

应用现在会看到两个查询参数。如果代码创建查询对象，并把 `red&limit=500` 作为 `q` 的值，就应生成：

```text
/search?q=red%26limit%3D500
```

这就是要测试的具体边界：输入语义字段，得到精确请求目标，目的地再解析出相同的语义字段。

不要只用字母和数字测试。它们会掩盖代理最容易暴露的故障。代理接收的是支持工单、问题标题、分支名称、文件路径、复制来的 URL 和自然语言。真实输入包含各种分隔符。

## 路径参数是一个路径段，不是未完成的 URL

除非 API 合同明确说明参数接受路径，否则应将路径参数视为单个路径段。只遵守这一条规则，就能消除大量歧义。

开发者经常这样拼接路径，因为读起来很直观：

```javascript
const target = base + "/projects/" + projectId + "/builds";
```

这段代码没有赋予 `projectId` 组件级别的含义。如果值包含 `/`、`?`、`#` 或 `%`，最终结果取决于后续代码如何处理 `target`。它还容易引发另一个错误：有人在日志中看到已经编码的值，为了“安全起见”再次调用 `encodeURIComponent`，最终得到另一个标识符。

应将路径段作为数据构建，每个段只编码一次，并且只连接由路由本身负责的分隔符。在 JavaScript 中，下面这个小 helper 能明确表达约定：

```javascript
function pathSegment(value) {
  if (typeof value !== "string" || value.length === 0) {
    throw new Error("project id must be a nonempty string");
  }
  return encodeURIComponent(value);
}

const path = "/projects/" + pathSegment(projectId) + "/builds";
```

这里使用 `encodeURIComponent` 是合适的，因为它会编码 `/`、`?`、`#`、`&` 和 `=`，避免这些字符改变路径、开始查询或引入片段。它仍会保留一小组 RFC 3986 字符，包括撇号和圆括号。通常这不会改变路径结构，但严格的 API 合同可能要求更严格的编码器。应根据 API 规范决定，不要凭习惯选择。

不要用 `encodeURI` 编码单个路径段。它会保留 URI 分隔符，因为它面向的是完整 URI。把 `north/ops` 传给它时，斜杠仍会保留，路由也会改变。这个建议很常见，只是因为函数名听起来合适，实际适用范围却不对。

路径当然也可以合法地携带多个段，例如 API 定义了 `/{owner}/{repository}`。应将它建模为两个字段，而不是一个自由格式的 `resourcePath` 字符串。如果端点确实需要接受可能包含斜杠的不透明标识符，可以考虑把它放进查询参数或 JSON 请求体。强行让不透明文本穿过路由层，会让所有人猜测编码后的斜杠到底如何处理。

还有一个客户端编码无法修复的路由决策。许多代理和框架会规范化 `.`、`..` 等点段，合并连续斜杠，或拒绝编码后的分隔符。应直接询问端点负责人：路由匹配发生在百分号解码之前还是之后？然后通过包含代理在内的已部署路由进行测试。开发机上单独运行的框架文档无法回答这个问题。

## 查询字符串需要明确的语法

查询字符串不是一个整体转义的字符串，而是一组字段，其语法由 API 决定。在代理安全调用端点之前，必须确定重复名称、空值、数组、布尔值、空格和重复项的处理方式。

WHATWG URL Standard 以及面向浏览器的 `URLSearchParams` API，会采用表单风格的查询序列化。在这种约定中，空格通常变成 `+`，字面加号则变成 `%2B`。许多服务器解析器也采用同样的约定。但 RFC 3986 本身并没有规定在通用 URI 中 `+` 表示空格。当一个组件使用通用解析器，另一个组件使用表单解析器时，这两点都很重要。

应使用查询构建器，而不是字符串模板：

```javascript
const query = new URLSearchParams();
query.set("q", userText);
query.set("include_archived", "false");
for (const label of labels) query.append("label", label);

const url = "https://api.example.test/search?" + query.toString();
```

当 `userText = "C++ & systems"` 时，具体写法可能是 `q=C%2B%2B+%26+systems`。采用表单解码的服务器应还原出 `C++ & systems`。回归测试应检查 API 的语义值，而不是要求每个库都用 `%20` 表示空格。在你会遇到的查询约定中，`%20` 和 `+` 都可能表示空格，但解析后字面加号必须仍是字面加号。

重复字段需要明确决定。下面是三种不同的合同：

```text
?label=bug&label=security
?label=bug,security
?label=["bug","security"]
```

第一种是重复名称，第二种是一个包含逗号的值，除非 API 另有规定，第三种只是看起来像 JSON 的字符串，除非服务器主动解析，否则并不是 JSON。不要只告诉代理“把 labels 放进 URL”，却不说明格式。应给操作一个数组参数，并让操作按照端点接受的唯一格式完成序列化。

重复的标量参数也会造成隐蔽故障。请求 `?role=user&role=admin` 可能根据框架不同得到第一个值、最后一个值、数组，或者直接报错。如果安全检查读取第一个值，而下游服务读取最后一个值，就会产生分歧。对于本应只出现一次的字段，应在你能控制的最早组件中拒绝重复项。

还应注意片段，因为它们经常干扰调试。`#section` 通常不会作为 HTTP 请求的一部分离开客户端。如果原始用户输入添加了 `#`，URL 对象可能在发送请求前移除其后的全部内容。数据需要放进路径或查询值时，应对其进行编码。不要只看源字符串日志，就认定那是服务器实际收到的内容。

## 百分号和解码顺序会生成不同的标识符

应在明确的边界只解码一次百分号转义。如果两个组件都解码同一输入，看似无害的值可能在第一次检查通过后变成分隔符。

以 `%252F` 为例。解码一次得到 `%2F`，解码两次得到 `/`。如果验证发生在两次操作之间，这一点就很重要。网关可能拒绝标识符中的原始 `/`，却允许 `%252F`，随后上游应用再次解码并拆分路由。`%252e` 变成 `%2e`，再变成 `.` 时也会出现同样的情况。

在设计讨论和测试中，应区分三个值：

1. 原始线路上的写法，例如 `%252F`。
2. 一次百分号解码后的值，例如 `%2F`。
3. 所有解析器和重写处理完成后的应用值，例如 `/`。

团队经常把这三个阶段都称为“URL”。这种模糊说法会导致错误审查，因为大家不知不觉中比较了不同阶段的值。

RFC 3986 建议 URI 生产者不要对同一个字符串重复编码或解码。这是正确建议，但不是实施方案。应定义操作序列化器接收已解码应用字符串的位置，也应定义服务器接收原始请求字节的位置。两者之间的组件必须保留转义，或者拒绝无法保留的形式。

不要为了方便而接受已编码输入。代理提示中如果要求“提供 URL 编码的项目 ID”，就等于让模型猜测 `%2F` 是数据还是指令。下一层也无法知道应保留百分号，还是将它编码为 `%25`。应接受普通文本字段，在操作层只编码一次；只有在确实接受原始 URL 时，才在相应边界拒绝格式错误的百分号转义。

Unicode 也会带来边界问题。URL 客户端通常会把文本转成 UTF-8 字节，再对选定组件中不能直接出现的字节进行百分号编码。服务器框架在查找用户或资源前，可能以不同方式规范化 Unicode。如果你的业务要求统一形式，应在应用层保持标识符的规范形式。不要试图用 URL 转义解决 Unicode 身份问题。转义只负责传输字节，不能决定两个外观相似的字符串是否代表同一个账户。

## 用普通但有针对性的输入测试完整链路

有效的编码测试应观察两件事：客户端发出的请求目标，以及接收端解析出的值。只测试其中一端，代理重写或框架解码仍可能在中间改变含义。

先在自己的测试环境中建立受控的回显处理器。服务器运行时能够提供原始请求目标时，应记录它，然后返回解析后的路径和查询字段。不要把凭据放进这个端点。它的任务是暴露序列化结果，而不是认证任何人。

下面这个 Node handler 展示了一种响应结构。它有意同时返回看起来接近原始请求的属性和解析后的值，因为两者能发现不同缺陷：

```javascript
import http from "node:http";

http.createServer((req, res) => {
  const url = new URL(req.url, "http://local.test");
  const pairs = [...url.searchParams.entries()];
  res.setHeader("content-type", "application/json");
  res.end(JSON.stringify({
    requestTarget: req.url,
    pathname: url.pathname,
    queryPairs: pairs
  }, null, 2));
}).listen(8787);
```

向它发送已知用例，并保留预期输出。例如，查询构建器收到 `C++ & systems` 时，输出中应有一个 `q` 参数，其解析值正好是 `C++ & systems`。手写查询通常会暴露问题：返回两个参数，或将加号改成空格。

```text
{
  "requestTarget": "/search?q=C%2B%2B+%26+systems",
  "pathname": "/search",
  "queryPairs": [["q", "C++ & systems"]]
}
```

测试套件应覆盖一个紧凑的矩阵，而不是数十个随机字符串：

- 空格、字面加号、百分号、与号、等号、问号和井号。
- 作为单个路径段的标识符中出现的斜杠和编码斜杠。
- 空字符串、缺失的可选字段和重复查询名称。
- 一个 Unicode 值，以及百分号转义看起来像会被再次解码的值。
- 粘贴进标识符字段、但实际是一整个 URL 的输入。

如果团队已经采用属性测试，可以使用它，但不要让生成的示例掩盖这些命名用例。这些用例说明了边界为什么存在。发生回归时，`encoded slash stays inside project_id` 比一个随机种子编号有用得多。

应通过生产流量实际使用的路径运行相同的集成测试。直接测试应用进程，无法说明代理是否拒绝 `%2F`、重写连续斜杠，或选择不同的重复查询值。如果生产边缘无法加入本地测试，就使用配置相同的预发布路由，并把这项测试作为发布检查的一部分。

## 给代理结构化参数，不要让它自由构造 URL

代理应选择操作并提供类型明确的参数。操作实现负责选择 HTTP 方法、允许的来源、路由模板、查询语法、请求头和编码。让代理直接提交完整 URL，会把这些独立控制合并成一个含义不明确的字符串。

一个收窄后的操作合同可以是：

```json
{
  "name": "get_project_builds",
  "input": {
    "project_id": "north/ops",
    "branch": "release+candidate",
    "limit": 25
  }
}
```

操作代码应将 `project_id` 验证为标识符，编码为一个路径段，把 `branch` 放入查询构建器，将 `limit` 验证为 API 允许范围内的整数，并根据固定来源构造 URL。模型不需要看到 bearer token，也不需要决定与号应该放在哪里。

这种分离还能防止来源混淆。以 `https://other.example` 开头的字符串不应出现在标识符字段中。如果操作确实要获取用户提供的 URL，应将它设计成单独操作，写明允许列表、DNS 和重定向行为，以及它存在的明确理由。不要通过名为 `callback` 或 `file` 的字段偷偷提供任意抓取能力。

对于查询参数中接受过滤语言的 API，要保持谨慎。`filter=status:open AND owner:me` 这样的字段包含两套语法：URL 查询序列化和过滤语言本身。URL 编码能让过滤器留在一个查询值中，却不会让过滤器本身变安全。应单独解析或限制内部语言，或者提供类型明确的过滤字段。

避免把机密放进 URL。查询字符串可能进入访问日志、部分场景下的浏览器历史、遥测数据和错误报告。凭据应放在 API 规定的授权机制中。编码 API token 并不能让它出现在查询字符串中变得安全。

## 批准有用，但无法修复格式错误的请求

人工授权与正确序列化解决的是两个不同问题。批准可以确认某个已识别的代理进程可以使用凭据，却无法说明百分号序列是否会在下游路由器中变成斜杠，也无法说明重复参数在解析后是否改变权限。

有人认为提示或批准卡片已经足够，因此不需要严格的请求构造。这种说法不成立。审查 `https://api.example.test/projects/%252Fadmin` 的人必须在脑中模拟链路上的每个解码器，才能知道最终结果。这不是公平的安全控制，尤其是在代理一次会话中可以发起很多调用的情况下。

在请求到达批准界面前，先执行确定性检查：

- 接受结构化字段，而不是预先拼好的请求目标。
- 在操作定义中固定来源、方法和路由模板。
- 每个路径段和查询值只序列化一次。
- 拒绝端点未定义的重复字段或格式错误字段。
- 通过已部署的请求路径测试最终目标。

Sallyport 可以让凭据留在支持 MCP 的代理之外，并要求批准代理进程，或要求每次使用选定凭据时都进行批准。操作层先构造出一个明确的请求时，这项人工控制才最有效。

日志也需要同时保留两个层次。记录获批的操作及其安全参数，然后保留实际请求目标和 HTTP 结果的脱敏表示。如果服务包含敏感查询字段，应隐藏其值，但保留参数名以及足够的结构信息，用于诊断意外重复。请求失败时，也不要因此记录授权请求头。

## 路由规范化可能击穿正确的客户端

即使客户端正确序列化，请求仍可能在基础设施边界发生变化。代理、负载均衡器、Web 应用防火墙和应用服务器，都可能对连续斜杠、点段、编码分隔符和无效转义采取不同处理。整条链路需要统一的路由合同。

假设客户端发送了以下路径：

```text
/files/reports%2F2025%2Fnotes
```

如果 API 期望一个不透明的文件 ID，应用参数应是 `reports/2025/notes`。但如果代理在转发前解码，就可能发送 `/files/reports/2025/notes`。配置为 `/files/:id` 的路由器可能拒绝它，另一个配置为 `/files/:folder/:year/:name` 的路由器则可能匹配到完全不同的处理器。这两种结果都不能证明客户端编码失败。

应为每条敏感路由做出明确决定。可以在边缘拒绝编码斜杠，并规定 ID 不能包含斜杠；也可以让它一路保留到处理器，并测试这一性质；还可以重新设计端点，把不透明数据移出路径。不能把行为交给依赖版本的默认值，然后把结果称为安全边界。

还要注意重定向。HTTP 客户端可能自动跟随重定向，而重定向后的 URL 可能带有不同规范化方式的路径或查询。对于携带凭据的操作，应明确是否允许重定向、目标来源是否必须一致，以及来源改变时是否剥离授权请求头。这虽然不同于百分号编码，但 URL 故障与重定向规则经常出现在同一段请求代码中。

如果你运营多个服务，应主动测试它们之间的差异。在非生产环境中，让同一个编码路径经过边缘层并直接到达应用。如果解析后的路径值不同，应在向代理开放该路由前修复问题。即使没有攻击者利用，解析器不一致也已经是运营问题。

## 回归套件应保留你原本想表达的含义

URL 测试的目的不是强迫所有组件使用某一种转义写法，而是保留操作参数与服务器端请求含义之间的关系，避免库和基础设施变化后破坏这一关系。

应在三个层次编写断言。单元测试验证路径段编码器会把 `a/b` 变成一个编码后的路径段，并确认查询序列化会保留值中的 `&`。操作测试将精确捕获发送到回显处理器的方法、来源、路径和查询对。集成测试则通过接近生产环境的边缘层运行，并断言处理器收到预期路由和参数。

API 合同含糊时，应写下歧义并消除它。“支持在 URL 中搜索文本”不是合同。应说明空 `q` 是否接受，`q=a+b` 表示加号还是空格，重复的 `tag` 是否允许，以及 ID 是否允许 `%2F`。一旦代理可以根据任意人类文本生成调用，这些细节就不再是偶然行为。

不要通过更早解码输入来掩盖失败的测试。提前解码通常会让用例看起来正常，却把歧义移到更不明显的层。应让原始用户文本一直保持为数据，直到组件专用的序列化器处理它，然后检查接收端实际解析出的结果。

Sallyport 的 Activity 日志可以帮助你比较代理获批的调用与返回结果，审计链还可以通过 `sp audit verify` 离线验证。应使用这条记录调查不一致，而不要把它当作决定端点接受什么内容的替代品。

我会添加的第一个测试非常普通：一个包含 `/` 的标识符，一个包含 `+` 和 `&` 的查询值，以及一个必须保持字面意义的百分号。如果你的操作无法准确说明服务器对这些输入会收到什么，它还没有准备好接受代理传来的用户文本。
