URL 编码的三个陷阱:空格、加号与双重编码

查询串里的 + 是空格,路径里的 + 是加号;同一个字符在路径与查询里的编码规则不同;编码两次会得到看似正常却永远匹配不上的值。

URL 编码只有一条规则(百分号加两个十六进制位),但它在三个地方各有一套习惯,混用就是 bug。

陷阱一:+ 只在查询串里表示空格

?a=b+c       → 值是 "b c"   (表单编码,+ 是空格)
/a+b         → 路径是 "/a+b" (路径里 + 就是加号)

这个不对称来自历史:查询串沿用了 application/x-www-form-urlencoded,路径没有。所以:

位置 空格 加号
查询串 + 或 %20 必须写成 %2B
路径 必须写成 %20 直接用 +

在查询串里传一个真的加号,必须编码成 %2B,否则另一端会解成空格。这是最经典的编码 bug。

陷阱二:路径与查询的保留字不同

encodeURIComponent 会编码 / ? & = #,encodeURI 不会。用错方向的后果:

// 想编码一个路径片段(不是整条路径)
encodeURIComponent('a/b');   // 'a%2Fb'  ← 路径里这是一段
encodeURI('a/b');            // 'a/b'    ← 变成了两段

// 想编码一个查询值
encodeURIComponent('a&b');   // 'a%26b'  ← 正确
encodeURI('a&b');            // 'a&b'    ← 被当成两个参数

规则很简单:编码单个组件用 encodeURIComponent,整条 URL 用 encodeURI。 拼查询串时永远用前者。

标准还要求编码 ! ' ( ) *,而 encodeURIComponent 不编这四个:

function strictEncode(text) {
  return encodeURIComponent(text)
    .replace(/[!'()*]/g, (ch) => '%' + ch.charCodeAt(0).toString(16).toUpperCase());
}

多数场景不编也能工作,但签名计算(OAuth 之类)会因为这几个字符产生不一致。

陷阱三:双重编码

对已经编码过的串再编一次,% 变成 %25:

原文       a b
编码一次   a%20b      ← 正确
编码两次   a%2520b    ← 解码一次得到 "a%20b",不是 "a b"

双重编码不会报错,只会得到错的值。 常见来源:框架自动编码了一次,代码里又编了一次。

排查手段是看请求里 % 的密度:出现 %25 基本可以确定编了两次。解码时也别自作主张「解到不能再解」—— 如果值本身就该含 %,多解一次就毁掉了原值。

两个实用规则

一、编码要在拼串的最后一步做。

const q = params.map(([k, v]) =>
  strictEncode(k) + '=' + strictEncode(v)
).join('&');

先拼后编会把分隔符也编掉。用 URLSearchParams 更省心,它已经处理了这些:

new URLSearchParams([['a', 'b c'], ['d', '1+2']]).toString();
// 'a=b+c&d=1%2B2'

二、不要信任解码结果做路径拼接。

%2E%2E%2F 解出来是 ../。服务端若先解码再拼路径,就得到了目录穿越。正确顺序是先按段拆路径、校验每一段,再解码,或者解码后拒绝任何含 / 与 .. 的结果。

URL 编码不是「把特殊字符换成百分号」这么简单。位置决定规则:路径与查询各一套,编码的时机决定成败。

← 返回文章列表

评论

…