雲端 Mac 上的 iOS Base64URL 嚴格解碼驗收
API 將一段二進位游標編碼為 Base64URL,iOS 用戶端卻偶爾把損壞的游標視為有效資料。問題不一定出在網路:寬鬆解碼、自動補齊填補字元,以及忽略非法字元,都可能將原本應該拒絕的輸入轉成位元組。在雲端 Mac 上,可以先用獨立的 Swift 腳本確立解碼契約,再將同一組測試案例移入 App 的測試目標,不必啟動模擬器就能檢查編碼的邊界條件。
先明確定義接受哪一種編碼
本文只接受非空、不含填補字元的 Base64URL:可用字元為英文字母、數字,以及 -、_,不接受空格、換行、+、/ 或 =。這是應用程式 API 明確選定的規則,不代表所有使用 Base64 的協定都應採用相同規則。
標準 Base64 與 Base64URL 不應在同一個入口靠猜測來相容。若舊 API 要求帶有填補字元,就應提供獨立的解析入口並分別測試,避免用戶端升級後,在不知不覺中擴大可接受的輸入範圍。
長度也有結構上的限制:不含填補字元的文字,其字元數除以四,餘數只能是零、二或三;餘數為一時,不可能構成合法編碼。範例將輸入上限設為 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 個位元組,但這只是編碼層的資源預算;業務欄位通常應設定更小的上限。
函式收到字串時,字串已經存在於記憶體中,因此這項限制無法取代網路回應的大小限制。接收層需要限制回應本文,解碼層限制欄位,結構解析層再限制集合的元素數量與巢狀深度,由三個層級分別負責。
還需要補齊三個邊界案例:
- 長度恰好達到上限的合法文字應成功。
- 超出上限一個字元就必須失敗,不能先截斷再解碼。
- 長度符合限制,但內容非法的文字仍應失敗。
也要避免一邊將解碼結果作為識別依據,另一條路徑卻接受寬鬆版本。快取鍵、去重鍵與簽章輸入採用哪一種表示法,應在 API 契約中統一,否則相同內容可能得到不同的處理結果。
納入迴歸測試並限制診斷資訊範圍
獨立腳本通過後,將解碼函式放入共用模組,並將測試案例移入 App 的單元測試目標。命令列腳本適合快速重現問題,App 目標的測試則負責找出實際相依性與建置設定帶來的差異,兩者不能長期各自維護一份實作。
除了固定案例,也可以針對不同長度的位元組陣列執行「編碼、解碼、逐位元組比較」。重點是涵蓋長度除以三時各種不同的餘數,因為它們決定最後一組編碼會使用多少尾端位元。接著修改末尾字元,確認非正規表示法無法蒙混過關。
失敗報告只保留錯誤類別、輸入長度與案例編號,不記錄真實權杖或完整游標。上線前確認:沒有自動裁剪、沒有忽略未知字元、沒有混用字元集,而且結構驗證是在解碼之後進行。最終目標不是讓更多輸入成功解碼,而是讓 API 只接受雙方約定的那一種表示法。
常見問題
為什麼解碼後還要重新編碼比對?
部分解碼器會接受未使用尾端位元非零的輸入。將結果重新編碼為正規的無填補 Base64URL 並逐字比對,可以拒絕同一位元組序列的非正規文字表示。
這個解碼器可以驗證權杖簽章嗎?
不可以。它只驗證編碼形式並回傳位元組,不驗證簽章、有效期限或發行者。權杖仍須經過獨立的密碼學驗證與業務規則檢查。