Base64 的换行与填充:为什么会破坏解码

2026-09-30 · 1263 词

两条格式规则造成的 Base64 失败,比其他所有原因加起来还多。两条都是标准的一部分,两条在实际数据里都经常被违反,而严格的解码器会拒收结果 —— 这意味着同一个字符串在一种语言里能用,在另一种里就失败。

第一条规则是:Base64 的输出常常被折成多行。第二条是:尾部用 = 补齐,让长度凑成 4 的倍数。两者都不改变数据,但都改变字符串,而解码器对"该多宽容"没有共识。

为什么是 76 个字符

Base64 比 JSON、HTTP/2 和邮件附件都更早。它被设计成能在乎行长度的系统里存活,其中占主导的是电子邮件:RFC 2045 把编码后的正文折在 76 个字符,这个上限的选择是为了留在 SMTP 的 998 字节行上限之内,同时让 quoted-printable 输出保持可读。每 4 个 Base64 字符正好表示 3 个字节,所以 76 个字符就是每行 57 字节 —— 这个看起来很别扭的数字,其实是"72 个字符再加一点余量"。

这就是为什么在 PEM 文件、MIME 正文和日志输出里你会看到这种形状:

iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAF
AAH/q842iQAAAABJRU5ErkJggg==

那个换行不是数据。它是传输层的家具,泄漏到了复制粘贴这一层,而把字符串当成不透明字节的解码器会在第一个 \n 处停下,并在第 76 个位置报一个非法字符。

填充为什么存在,以及余数为什么重要

每 4 个 Base64 字符编码 3 个字节。当输入长度不是 3 的倍数时,编码器用 = 给输出补位:

所以算上填充之后,编码长度永远是 4 的倍数。反过来用,就得到这一整块领域里最有用的一条诊断规则:

归一化之后,一个完整 Base64 字符串的长度模 4 只能是 0、2 或 3。余数为 1 是不可能的。

余数 1 并不意味着"稍微有点不规范",而是意味着有字符丢失。编码里不存在余 1 的情况,所以必然是某个输入字节对被切成了两半。实践中成因几乎总是截断:被截到 1024 字符的日志行、选中文本时丢掉折行的终端、带 VARCHAR(n) 限制的数据库列,或者复制时早停了一个字符。

这一点值得说明白,因为它区分了"能修的问题"和"不能修的问题"。解码器可以修复缺失的填充 —— 补 = 是确定且无损的;它无法修复缺失的字符;它能产出的一切都是损坏的图片 —— 而一个"顺手补上填充再解码"的工具,交给你的是一张你看不出错在哪的图。

不同解码器的处理方式

你能得到什么行为,完全取决于用哪个实现,而这些差异在任何地方都没有被显著地写出来。

Python 严格且明确。base64.b64decode(s) 会忽略换行,但拒绝其他垃圾;加上 validate=True 后会拒绝任何字母表之外的字符:

import base64
raw = base64.b64decode(payload, validate=True)   # 输入有问题时抛 binascii.Error

Node 是另一个极端。Buffer.from(s, 'base64') 会静默丢弃它不认识的字符。一个含空格、引号和零散文句的字符串,它会毫无怨言地解码:

Buffer.from('iVBO\nRw0K Ggo=', 'base64').length   // 10 —— 不报错,垃圾被跳过

这种宽容在你确信输入基本正确时很方便,在不确信时很危险:被截断的字符串依然返回字节,只是少了些,而没有任何东西告诉你这个差别。

浏览器在中间。atob 遇到换行会抛 InvalidCharacterError —— 但那是在严格符合规范的形态下;历史上它曾忽略空白。先归一化就能消除这份不确定性:

const clean = payload.replace(/[\r\n\t ]+/g, '');
const bytes = Uint8Array.from(atob(clean), (c) => c.charCodeAt(0));

一个不会丢信息的归一化顺序

顺序很重要,因为某些修复会改变下一步检查看到的东西。下面这个顺序就是 Base64 转图片 采用的顺序,并且会报告每一步做了什么:

  1. 剥掉 data: 前缀(如果有)—— 到第一个逗号为止。没有逗号就没有前缀。
  2. 解码百分号编码(%3D → =)。这一步必须在字母表检查之前,否则 % 看起来就是非法字符。
  3. 删除空白与换行。
  4. 剥掉首尾引号 —— 从 JavaScript、JSON 或 shell 脚本里复制字符串时会出现。
  5. 反转义字面的 \n 和 \t —— 编码后的字符串本身被存进 JSON 字符串时会出现。
  6. 转换 URL 安全字母表(- → +、_ → /),如果这些字符存在的话。
  7. 修正填充 —— 按长度补上缺的 =,或者删掉多余的。
  8. 检查余数。 如果是 1,停下并告诉用户数据被截断了,不要解码。

第 7 和第 8 步这一对,把有用的解码器与看起来合理的解码器区分开。没有第 8 步,你会静默地交付一张损坏的图片;没有第 7 步,你会拒收每一个在传输中丢掉填充的字符串,而那占了相当大一部分。

什么时候可以接受被修复过的字符串

修复不等于校验。补填充是安全的,因为它可逆,而且由数据自身决定;删空白同理。但一旦你修好了形式,对内容的唯一真实检查就是结构性的:解出来的字节流,看起来像它本该是的东西吗?

这就是工具要报告它改了什么、而不是悄悄动手的原因。"已删除空白与换行(57 处)"是信息:它说明这个字符串走过一条懂 MIME 的通道。"已补 2 个填充字符"说明来源把它丢了 —— 常常是 URL、查询参数,或者被 trim 过的 JavaScript 字符串。两者都很正常,而当出来的图片不是你预期的图片时,这两条信息都值得知道。

唯一不应该静默进行的修复,是从数据中间删字符。如果解码器为了把字符串变合法,不得不丢掉空白之外的任何东西,诚实的答案是:这份输入不是 Base64 —— 这个问题的"头"那一半见 data URI 到底允许什么,而当你已经解出了东西却仍然显示不出来时,完整的排查顺序见 Base64 图片不显示。

相关阅读

试用 Base64 转图片工具 →