云端 Mac 上的 iOS Base64URL 严格解码验收

云端 Mac 上的 iOS Base64URL 严格解码验收

接口把一段二进制游标编码成 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 并逐字比较,可以拒绝多个文本表示同一字节序列的情况。

这个解码器能验证令牌签名吗?

不能。它只验证编码形式并返回字节,不检查签名、有效期或发行方。涉及令牌时,必须继续执行独立的密码学验证与业务校验。

用自己的流水线验证云端 Mac

ArmRDP 提供独享物理 Mac mini,非虚拟机。按天起租,先核对工具链与构建结果,再选择适合团队的租期。

开始租用云端 Mac

让配置有据可查,让构建可以复现

核对机型、租期与接入步骤,再把任务交给独享物理节点。