接口把一段二进制游标编码成 Base64URL,iOS 客户端却偶尔把损坏的游标当成有效数据。问题未必在网络:宽松解码、自动补齐以及忽略非法字符,都可能把原本应当拒绝的输入变成字节。在云端 Mac 上,可以先用独立 Swift 脚本固定解码契约,再把同一组样本移入应用测试目标,不必启动模拟器就能检查编码边界。
先明确接受哪一种编码
本文只接受非空、无填充的 Base64URL:字母、数字以及 -、_,不接受空格、换行、+、/ 或 =。这是应用接口的明确选择,不代表所有使用 Base64 的协议都应采用相同规则。
标准 Base64 与 Base64URL 不应在同一个入口里靠猜测兼容。若旧接口要求带填充,应提供独立解析入口,分别测试,避免客户端升级后悄悄扩大可接受输入集合。
长度也有结构约束:无填充文本的字符数除以四,余数只能是零、二或三;余数为一无法组成合法编码。示例把输入上限设为 65536 个 UTF-8 字节,这是测试用的应用策略,不是协议规定值。
解码只恢复字节,不证明内容可信。用于令牌、游标或签名参数时,仍须验证其结构、来源以及业务有效性。
用回编码约束规范表示
保存下面代码为 base64url-check.swift。先限制输入大小,再检查字母表,最后只在内部补齐填充。不要使用忽略未知字符的选项,也不要提前删除空白字符。
import Foundation
enum DecodeFailure: Error {
case size
case alphabet
case length
case payload
case noncanonical
}
func decodeBase64URL(
_ text: String,
maxEncodedBytes: Int = 65536
) throws -> Data {
let count = text.utf8.count
guard count > 0, count <= maxEncodedBytes else {
throw DecodeFailure.size
}
let valid = text.utf8.allSatisfy { byte in
(65...90).contains(byte)
|| (97...122).contains(byte)
|| (48...57).contains(byte)
|| byte == 45
|| byte == 95
}
guard valid else {
throw DecodeFailure.alphabet
}
let remainder = count % 4
guard remainder != 1 else {
throw DecodeFailure.length
}
var padded = text
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
padded += String(repeating: "=", count: (4 - remainder) % 4)
guard let data = Data(base64Encoded: padded) else {
throw DecodeFailure.payload
}
let canonical = data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
guard canonical == text else {
throw DecodeFailure.noncanonical
}
return data
}
最后一次比较不能省。单字节 FF 的规范表示是 _w;_x 的未使用尾位非零,某些解码器仍可能还原出相同字节。回编码比较让验收不依赖底层解码器究竟有多宽松。
这里返回 Data,不直接转换为字符串。合法二进制不一定是 UTF-8;需要文本的上层必须另行检查编码,不能用替换字符掩盖损坏内容。
把失败样本写成可执行断言
把以下测试接在同一个文件末尾。测试失败通过抛错令脚本非零退出,不依赖可能在优化构建中被移除的断言。
enum CheckFailure: Error {
case mismatch
case acceptedInvalid
}
let validCases: [(String, [UInt8])] = [
("AA", [0]),
("_w", [255]),
("-_8", [251, 255]),
("Zm9v", [102, 111, 111])
]
for (text, expected) in validCases {
guard try decodeBase64URL(text) == Data(expected) else {
throw CheckFailure.mismatch
}
}
let invalidCases = [
"", "A", "Zg==", "Zg\n", "+w", "/w", "_x", "中文"
]
for text in invalidCases {
do {
_ = try decodeBase64URL(text)
} catch is DecodeFailure {
continue
}
throw CheckFailure.acceptedInvalid
}
do {
_ = try decodeBase64URL("AAAA", maxEncodedBytes: 3)
} catch is DecodeFailure {
exit(0)
}
throw CheckFailure.acceptedInvalid
在已配置命令行工具的云端 Mac 上运行:
xcrun swift base64url-check.swift
成功时没有输出,退出码为零。_x 负责覆盖尾位检查;带换行的样本防止以后有人加入自动清理;中文样本确认字母表按 ASCII 字节校验,而不是按“看起来像字母”判断。
检查大小边界与调用位置
输入上限要放在字符串替换和解码之前,否则超长内容已经制造了额外副本。这里允许的最大规范输入可解出 49152 字节,但这只是编码层预算;业务字段通常应设置更小的上限。
函数收到的字符串已经存在于内存,因此它不能代替网络响应大小限制。接收层需要限制响应体,解码层限制字段,结构解析层再限制集合数量与嵌套深度,三层分别负责。
还要补齐三个边界样本:
- 长度恰好达到上限的合法文本应成功。
- 超出上限一个字符必须失败,不能先截断再解码。
- 长度符合限制但内容非法的文本仍应失败。
不要把解码结果当作身份标识之前,又在其他路径里接受宽松版本。缓存键、去重键与签名输入采用哪种表示,应在接口契约里统一,否则同一内容可能得到不同处理结果。
接入回归测试并保留诊断边界
独立脚本通过后,把解码函数放入共享模块,样本迁入应用的单元测试目标。命令行脚本适合快速复现,应用目标测试负责发现真实依赖和构建配置差异,两者不能长期各维护一份实现。
固定样本之外,可对不同长度的字节数组执行“编码、解码、逐字节比较”。重点覆盖长度除以三的不同余数,因为它们决定最后一组编码使用多少尾位。再修改末尾字符,确认非规范表示不会蒙混过关。
失败报告只保留错误类别、输入长度和样本编号,不记录真实令牌或完整游标。上线前确认:没有自动裁剪、没有忽略未知字符、没有混用字母表,且结构验证发生在解码之后。最终目标不是让更多输入解码成功,而是让接口只接受双方约定的那一种表示。
常见问题
为什么解码成功以后还要重新编码比较?
部分解码器会接受未使用尾位非零的输入。将解码结果重新编码为规范的无填充 Base64URL 并逐字比较,可以拒绝多个文本表示同一字节序列的情况。
这个解码器能验证令牌签名吗?
不能。它只验证编码形式并返回字节,不检查签名、有效期或发行方。涉及令牌时,必须继续执行独立的密码学验证与业务校验。