Python urllib Emoji 头部编码踩坑记:UTF-8 Smuggling 的正确姿势

Python urllib Emoji 头部编码踩坑记:UTF-8 Smuggling 的正确姿势

2026-06-05

背景

cam-filter CI 流水线里有一个通知脚本 ntfy_notify.py,用 Python urllib.request 把处理结果推到 ntfy.sh。标题里带 emoji(⏳✅❌🎥)和中文字段名,比如 🎥 cam-filter 开始处理

cam-filter CI 中 emoji 通知的 UML 架构示意

跑了几个月一直正常,直到某天改了 emoji 映射——加了一个 📷(camera)到标题里。流水线直接崩了:

File ".../urllib/request.py", line ... in putheader
UnicodeEncodeError: 'latin-1' codec can't encode character '\U0001f4f7' in position ...

根因

Python 的 http.client.HTTPSConnection.putheader() 硬编码用 ISO-8859-1(latin-1) 编码 HTTP header value。latin-1 只能编码 0-255 的字符,emoji(U+1F000 以上)和中文字符(U+4E00 以上)全部超出范围,直接抛异常。

这个问题不限于 urllib。requests 库底层也走 http.client,早期版本同样踩过这个坑(GitHub issues #1926、#2838)。

第一轮修复:errors=”replace”(失败)

第一反应是加一个兜底编码函数:

def _safe_header(value: str) -> str:
    return value.encode("latin-1", errors="replace").decode("latin-1")

errors="replace" 把不能编码的字符全部替换成 ?。结果:
- 中文”开始处理” → ????
- 不在 shortcode 映射里的 📷?
- 通知标题显示 📷🎥 cam-filter ????

第一轮修复后通知栏中出现问号占位符

用户反馈:“标题还有问号”

教训:编码方案不能丢信息。如果目标编码不支持这个字符,说明方法不对——不是”替换”能解决的。

第二轮修复:UTF-8 Smuggling(正确姿势)

正解是用 UTF-8 smuggling——和 requests 库用的同一手法:

def _safe_header(value: str) -> str:
    try:
        value.encode("latin-1")
        return value  # already safe
    except UnicodeEncodeError:
        # UTF-8 bytes → pretend they're latin-1 code points
        return value.encode("utf-8").decode("latin-1")

转义映射表,把 emoji 转成 ntfy 官方 shortcode 追加到 Tags header,让通知栏显示 emoji 图标而不是原始字符:

_EMOJI_SHORTCODES = {
    "\u23f3": "hourglass_not_done",  # ⏳
    "\u2705": "white_check_mark",     # ✅
    "\u274c": "cross_mark",           # ❌
    "\u26a0": "warning",              # ⚠️
    "\u26a0\ufe0f": "warning",        # ⚠️ (with VS16)
    "\U0001f3a5": "movie_camera",     # 🎥
    "\U0001f4f7": "camera",           # 📷
    "\U0001f534": "red_circle",       # 🔴
}

提取逻辑内嵌在 send() 里,边扫描标题边生 clean_title 和 shortcode 标签:

原理

  1. value.encode("utf-8") → 把 Unicode string 编码为 UTF-8 字节序列(每字节 0-255,完全在 latin-1 范围内)
  2. .decode("latin-1") → 把这些字节”伪装”成 latin-1 码点
  3. Wire 上发送的是完整的 UTF-8 字节序列
  4. 服务端(ntfy.sh 用 Go 写)收到后按 UTF-8 解析,正确还原 emoji 和中文

UTF-8 Smuggling 字节流:原始字符 → UTF-8 编码 → latin-1 解码 → Wire 传输 → 服务端还原

最小演示:

header = "🎥 cam-filter 开始处理"

# ❌ errors="replace" 丢信息
safe_bad = header.encode("latin-1", errors="replace").decode("latin-1")
# → "??? cam-filter ????"

# ✅ UTF-8 smuggling 保信息
safe_good = header.encode("utf-8").decode("latin-1")
# wire bytes: \xf0\x9f\x8e\xa5 cam-filter \xe5\xbc\x80...
# 服务端收到后:🎥 cam-filter 开始处理

最终 send() 函数把 emoji 提取、UTF-8 smuggling、HTTP POST 串在一起:

def send(topic, title, message, tags=None):
    msg_bytes = message.encode("utf-8")

    # 从标题提取 emoji → ntfy shortcode,追加到 Tags
    title_tags = []
    clean_title = title
    for emoji_char, shortcode in _EMOJI_SHORTCODES.items():
        if emoji_char in clean_title:
            clean_title = clean_title.replace(emoji_char, "").strip()
            title_tags.append(shortcode)

    all_tags = []
    if tags:
        all_tags.append(tags)
    all_tags.extend(title_tags)
    tags_header = ",".join(all_tags) if all_tags else None

    headers = {"Title": _safe_header(clean_title)}
    if tags_header:
        headers["Tags"] = _safe_header(tags_header)

    req = urllib.request.Request(
        f"https://ntfy.sh/{topic}",
        data=msg_bytes,
        headers=headers,
        method="POST",
    )
    urllib.request.urlopen(req, timeout=10)

踩坑总结

症状 根因 教训
latin-1 限制 UnicodeEncodeError putheader() 硬编码 latin-1 底层库有编码假设
errors=”replace” 问号占位 丢信息式编码 编码不能丢信息
shortcode 不完整 漏映射的 emoji 触发报错 字典维护遗漏 映射表需要持续维护
Git rebase 竞态 CI 持续 push 打乱 rebase 多人/流水线并行 操作前先看 CI 状态

附录

ntfy 通知最佳实践:
- emoji 用 Tags header 传官方 shortcode,不要放 Title 里
- Title 头的非 ASCII 字符用 UTF-8 smuggling
- 正文正文用纯文本

社区讨论:
- CPython Issue #26045 — http.client 的 latin-1 限制
- CPython Issue #105981
- requests/issues/1926

RFC 背景: HTTP/1.1 规定 header value 用 ISO-8859-1(RFC 7230 §3.2.6)。但实践中服务端通常接受 UTF-8 bytes,真正卡住的是客户端库的编码检查。

标签: Python HTTP 编码 踩坑