保留与未保留的分界
RFC 3986 把字符分成三类:未保留字符(字母、数字与 - . _ ~)在 URI 中永远不需要编码;保留字符分为通用分隔符(: / ? # [ ] @)与子分隔符(! $ & ' ( ) * + , ; =),它们在 URI 中承担语法作用;其余字符必须编码。判断原则是「这个字符在当前位置是结构还是数据」:作为结构必须原样保留,作为数据必须转义。参数值里的 & 与 = 属于典型数据,必须编码,否则会被解析成新的参数。
基本信息
| 未保留字符 | 字母、数字、- . _ ~,不需要编码 |
|---|---|
| 通用分隔符 | : / ? # [ ] @,构成 URI 结构 |
| 子分隔符 | ! $ & ' ( ) * + , ; =,常见于查询串语法 |
| 核心规则 | 作结构保留原样,作数据必须编码 |
| 百分号 | 本身作为数据时写作 %25 |
| 实践要点 | 逐参数编码,而不是编码整条 URL |
典型使用场景
- 参数值含特殊字符:搜索词里有 & 或 =,必须编码才能正确传递
- 路径参数:包含斜杠的值需要编码或改用查询参数承载
- 签名计算:签名基串按规范编码,任何多编或少编都会校验失败
- 网关与代理规则:改写规则需理解保留字符语义,避免破坏结构
- 安全校验:参数注入与路径穿越常通过未编码或重复编码实现
编码与组装示例
编码与组装示例
# 逐参数编码后组装:值里的 & 与 = 会被正确转义
python -c "import urllib.parse as u; print('https://e.com/s?' + u.urlencode({'q':'a&b=c', 'n':'x y'}))"
# 单独观察:哪些字符被编码,哪些保持原样
python -c "import urllib.parse as u; print(u.quote('a&b=c~-._:/'))"
# 百分号作为数据要写成 %25
python -c "import urllib.parse as u; print(u.quote('100%'), u.unquote('100%25'))"
# 路径参数含斜杠时:编码斜杠或改用查询参数
python -c "import urllib.parse as u; print(u.quote('a/b', safe=''))"字符处理判断
| 字符 | 类别 | 作数据时 |
|---|---|---|
| & | 子分隔符 | 编码为 %26,否则会被当成参数分隔 |
| = | 子分隔符 | 编码为 %3D,否则会被当成键值分隔 |
| + | 子分隔符 | 在查询串中常表示空格,作数据需编码为 %2B |
| / | 通用分隔符 | 路径中作数据需编码为 %2F |
| # | 通用分隔符 | 作数据需编码为 %23,否则会被当成片段起点 |
| - . _ ~ | 未保留字符 | 保持原样,编码后可能与签名不符 |
「多编码一次不会出错」是错误认识:对未保留字符做编码会改变字符串,而许多签名与校验流程要求严格按规范编码,结果表现为「同样的逻辑有时成功有时失败」,排查成本很高。
参数注入是常见漏洞:值里的 & 未编码就能让攻击者追加参数,覆盖服务端信任的字段。防御做法是参数值必须编码,服务端解码后再做业务校验,不要依赖客户端已编码这一假设。
组件不同规则不同:查询串与路径允许的原样字符不一样,片段与用户信息部分也有各自限制。把「一套编码函数用遍全 URL」容易在路径与片段上出错,稳妥做法是按组件分别处理。