2225 字
约 7 分钟
16
S02-A21 · URL 编码:一次踩全的坑

一个搜索接口用 ?q= 传关键词,前端用 encodeURI 编码。用户搜"a&b"时返回空——因为 encodeURI 不编码 &,于是 q=a&b 被解析成"q=a" 加一个额外参数"b"。而同一份代码在搜中文时又出现乱码,因为另一处用了 escape。

URL 编码有三个函数、三层转义语境、以及"什么时候会二次编码"的问题。这个"不报错"让它们特别难查:服务端拿到的是一个"合法但不正确"的参数,于是它的行为"看起来正常",只是结果不对。
而这些坑的共同点是:它们不报错,只是把参数切成别的意思。


1. 三种函数的分工

函数 编码范围
encodeURI 保留 : / ? # & = + 等
encodeURIComponent 保留很少(适合"值")
escape 已废弃,且不处理 Unicode 正确

关键规则:"路径与查询串的结构"用 encodeURI,"参数值"用 encodeURIComponent。

第二项是"值"的正解:它会把 &、=、+、# 全部编码第二项的"通用性"很强:它不仅能编码查询值,也能编码"路径中的一段"——但要注意它会编码 /,所以"整条路径"不能直接用它编。
,于是"值里的结构字符"不会破坏 URL。

// 反例:用 encodeURI 编码"值",& 不被编码
const url = `/search?q=${encodeURI("a&b")}`;      // → ?q=a&b(被切成两个参数)
// 正例:编码值
const url2 = `/search?q=${encodeURIComponent("a&b")}`;  // → ?q=a%26b
// 第三方:路径段落也要单独编码(但它允许 / 吗?——不允许,逐段编码)

2. 三层"转义语境"

层 需要编码什么
路径段 / 之外的一切
查询串的值 & = # + 与空格
表单(x-www-form-urlencoded) 空格变 +(与 %20 不同)

第三项的"来源"是历史:HTML 表单编码早于 URL 标准,于是它把空格规定成 +——而两者在后端常由"同一段解码代码"处理,于是混用。
第三项是"经典错配":查询串里空格可以是 %20,而表单编码里空格是 +——把两者混用,会出现"空格变成加号"这类现象。

// 同一段文字在三种语境下的编码
原文:a b&c
路径段:a%20b%26c
查询值:a%20b%26c(或 a+b%26c,某些实现)
表单:  a+b%26c          ← 空格变成 "+"
// 判据:这段文字会放在 URL 的哪一层

3. "二次编码"的三种来源

来源 表现
前端编了、后端又编 %2520(% 被编成 %25)
网关重写 同上
拼接时又过一遍 同上

看到 %25 就说明"编码了两次":**% 本身被编码成 %25**第二项(网关重写)最容易被忽略:应用侧只编了一次,而网关"再规范化一次"——于是"代码看起来对",问题在配置里。
,于是 %20 变成 %2520,解码一次得到 %20(字面量),而不是空格。

这个"可逆"是个陷阱:它让"双解码"看起来像"修好了",而修法本身引入了安全漏洞——于是"功能正确、安全错误",比"功能错误"更难发现。
这类问题最麻烦的地方在于"它是可逆的":解码两次就对了——于是有人用"解码两次"来绕过,而那会让"真的含 % 的值"出错。

4. 服务端该怎么处理

做法 说明
框架自动解码一次 多数框架默认
手动再解码 会引入"双解码"漏洞
只解码、不二次 正解

第二项是"安全"问题:**攻击者可以提交 %252e%252e%252f(两次编码的 ../)**第二项的"经典场景"是"路径穿越":应用解码两次后把 %2e%2e%2f 还原成 ../,于是"越过目录限制"——而它的入口只是一个"文件名参数"。
,若应用解码两次,就会"越过目录限制"。

所以规则是"只解码一次,且不对解码结果再次解码"。

// 反例:手动再解码一次(引入双解码风险)
String q = URLDecoder.decode(request.getParameter("q"), UTF_8);   // 框架已解码过
// 正例:直接用框架解码后的值
String q2 = request.getParameter("q");
// 需要"规范化"时:只做"字符集转换",不做"再解码"

"已经编码过的值"再拼一次,是最常见的二次编码来源——而它常出于"安全"的动机:

// 反例:把"已编码的字符串"再编码一次
const already = encodeURIComponent("a b");     // "a%20b"
const url = `/search?q=${encodeURIComponent(already)}`;   // "a%2520b"(二次)
// 正解:编码只做一次,且"在拼 URL 的那一刻"做
const url2 = `/search?q=${encodeURIComponent("a b")}`;     // "a%20b"
// 判据:这个值现在是"原文"还是"已编码"?后者不要再编

5. 中文与字符集

现象 成因
乱码 编码用 UTF-8、解码用 ISO-8859-1
部分字乱 混合编码
方框 字体/字符集不支持

第一项的"判定"是看字节:中文 UTF-8 是 3 字节,被当 ISO-8859-1 解就变成 3 个乱码字符——于是"字数变多"是个信号。
第一项在"老系统"里常见:前端 UTF-8 编码,容器默认 ISO-8859-1 解码,于是"两个字节变两个字符"。

修法是"显式指定字符集":请求参数、响应、连接串三处都要写 UTF-8。这个"三处"要一起改:只改容器不改连接串,会出现"查询正常、写入乱码"——因为"读"与"写"走的地方不同。

// 三处字符集(缺一处就可能乱码)
前端:encodeURIComponent(总是 UTF-8)
容器:request.setCharacterEncoding("UTF-8") / server.servlet.encoding.charset=UTF-8
数据库:连接串 characterEncoding=utf8&useUnicode=true

"编码是否只做过一次"可以用一个往返测试确认,成本十秒:

// 往返测试:编码一次再解码一次,应当等于原文
orig='a b&c/中'
enc=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=''))" "$orig")
dec=$(python3 -c "import urllib.parse,sys;print(urllib.parse.unquote(sys.argv[1]))" "$enc")
[ "$dec" = "$orig" ] && echo "ok(一次编码)" || echo "mismatch(可能二次编码)"

6. 排错:五种"URL 编码问题"

现象 成因 处理方向
参数被切断 值里的 & 未编码 用 encodeURIComponent
出现 %25 二次编码 去一次编码
中文乱码 编码/解码字符集不一致 显式 UTF-8
空格变 + 表单编码混用 区分语境
安全扫描报"双解码" 手动再解码 删除二次解码

第四项(空格变 +)的"排查"是"看后端有没有把 + 当空格":若它在吃"表单编码"的解码器,那"查询串里的 +"就会被误当空格。
第三项的"判定"很直接:看乱码的形态——"测试"(UTF-8 被当 ISO-8859-1 解)与"???"(字符集丢失)不同。

最后一个"契约"视角:"编码规则"是客户端与服务端之间的约定,所以要写进接口文档。

// 接口文档里该写清的三行
query 编码:UTF-8 + percent-encoding(空格用 %20,不用 +)
已编码的值:不要重复编码(服务端只解码一次)
特殊字符:`& = # +` 出现在值里必须编码
// 收益:前端与后端"各自以为对"的情况从源头减少

7. 边界:什么情况不必精细处理

场景 为什么 做法
纯 ASCII、无特殊字符的参数 编码差异不影响 直接拼
内部固定参数(如 page=1) 无特殊字符 不必
已在框架层统一编码 框架负责 跟随
用 URLSearchParams 构造 自动编码 不必手写

判断口径一句话:"这个参数值会不会包含结构字符或非 ASCII"。最后一条与"构造方式"有关的边界:用 URLSearchParams(或等价库)构造查询串,它自动按"值"编码——于是"手写 ?a=${x}&b=${y}"这类错误从源头上消失。
会(搜索词、文件名、地址),就必须按"值"编码;不会(数字、枚举),简单拼接即可。


8. 自测

  1. 三种函数的分工是什么?为什么"编码值"必须用 encodeURIComponent?
  2. 三层转义语境里,表单与查询串对空格的处理差别是什么?
  3. 看到 %25 说明什么?为什么"解码两次"是危险的修法?
  4. 服务端为什么"只能解码一次"?双解码会导致什么攻击?
  5. 中文乱码的两种形态分别指向什么原因?
  6. 判断"是否需要精细编码"的那个问题是什么?
S02-A21 · URL 编码:一次踩全的坑
https://www.clxhxhhr.top/posts/6597/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。