阅读需 8 分钟

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

URL 编码故障可能让代理调用错误的 API 路由,或改变查询数据。先测试路径、查询、百分号转义和特殊字符。

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 没有定义统一的查询语法。大多数被笼统称为“编码问题”的故障,都与这一区别有关。

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

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

如果 project_idnorth/ops,下面两个请求目标并不等价:

/projects/north/ops
/projects/north%2Fops

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

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

/search?q=USER_TEXT

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

/search?q=red&limit=500

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

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

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

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

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

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

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

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

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

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

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 中 + 表示空格。当一个组件使用通用解析器,另一个组件使用表单解析器时,这两点都很重要。

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

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+ 都可能表示空格,但解析后字面加号必须仍是字面加号。

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

?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 身份问题。转义只负责传输字节,不能决定两个外观相似的字符串是否代表同一个账户。

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

用保险库锁定操作
保险库锁定后,所有 HTTP 和 SSH 操作都会在入口处被拒绝。

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

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

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

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。手写查询通常会暴露问题:返回两个参数,或将加号改成空格。

{
  "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,会把这些独立控制合并成一个含义不明确的字符串。

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

{
  "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 和重定向行为,以及它存在的明确理由。不要通过名为 callbackfile 的字段偷偷提供任意抓取能力。

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

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

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

检查代理调用历史
当序列化后的请求返回异常结果时,对照查看 Sessions 和 Activity 日志。

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

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

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

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

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

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

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

离线验证审计链
运行 sp audit verify,即使没有解密密钥,也能离线检查加密审计链。

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

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

/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 离线验证。应使用这条记录调查不一致,而不要把它当作决定端点接受什么内容的替代品。

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

常见问题

如何测试 API 客户端是否正确编码了 URL?

测试离开客户端的字节、服务器或代理收到的 URL,以及应用最终使用的路由或查询值。这几个结果可能不同,因为客户端库会序列化输入,中间组件可能对其规范化,框架还可能进行解码。只检查最终 HTTP 状态码,会漏掉导致问题的解析差异。

路径和查询字符串可以使用同一个 URL 编码函数吗?

不能。路径段用于标识资源层级中的一个部分,查询组件则承载名称和值的组合。路径中的斜杠与查询值中的斜杠,编码后的影响不同,因此使用一个通用转义函数很难形成清晰可靠的约定。

为什么 API 查询字符串中的加号会变成空格?

在 RFC 3986 描述的通用 URI 语法中,加号没有特殊含义。但许多表单风格的查询解析器会依据 application/x-www-form-urlencoded 约定,把加号解码为空格。加号表示实际数据时,应将它编码为 %2B。

路径参数中的斜杠应该编码为 %2F 吗?

通常应在进入单个路径段前,将属于用户数据的斜杠编码为 %2F。然后确认请求路径上的每个组件都会保留它,而不是先解码再拆分路由。有些服务器栈会拒绝编码后的斜杠,因此将不透明标识符放进查询参数或请求体,可能是更安全的 API 设计。

URL 值被重复编码会有危险吗?

可能危险。一个解码器可能把 %252F 变成 %2F,第二个解码器再把它变成斜杠,从而改变路由或验证结果。解码一次后,应拒绝不符合预期的百分号转义,并测试完整请求路径,而不是只信任其中一个组件。

代理应该如何在查询字符串中发送数组?

?tag=a&tag=b 通常表示重复参数,而 ?tag=a,b 则是一个包含逗号的值,除非 API 另有规定。代理应使用 API 已声明的表示方式,测试则应确认空值、重复名称和顺序最终如何到达服务器。

让 AI 代理根据用户输入构造完整 URL 安全吗?

应将不可信文本放进结构化工具参数,再由操作层根据目标位置序列化每个字段。不要让代理通过字符串拼接组装完整 URL。这样就能在请求执行前拒绝外部来源、用户信息组件或异常片段。

调试 URL 编码故障时应该记录什么?

只记录已解码的 URL,可能会掩盖改变请求的编码字节。应记录经过安全脱敏的最终请求目标,以及解析后的路径和查询字段。不要为了方便调试而把凭据放进 URL。

人工批准能阻止 URL 编码攻击吗?

批准只能说明某个进程可以发起调用,不能说明 %252e%252e%252f 是否会被代理解码两次,最终变成下游的另一条路径。请求构造仍需要在批准边界之前进行确定性验证。

URL 编码回归测试应包含哪些特殊字符?

使用本地回显端点或受控测试服务,并覆盖空格、加号、百分号、编码后的分隔符、Unicode、重复查询名称和空值。将原始请求目标与服务器解析出的字段进行比较。客户端库、代理或 API 框架发生变化时,继续保留这些回归用例。

Sallyport

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

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