クラウド Mac で検証する iOS Base64URL 厳密デコード
API がバイナリのカーソルを Base64URL でエンコードしているのに、iOS クライアントが破損したカーソルを有効なデータとして扱ってしまうことがあります。原因は必ずしもネットワークとは限りません。寛容なデコード、自動パディング、不正文字の無視によって、本来拒否すべき入力がバイト列に変換されることもあります。クラウド Mac なら、まず独立した Swift スクリプトでデコードの入力契約を固め、そのサンプルをアプリのテストターゲットに移せます。エンコードの境界条件を確認するだけなら、シミュレータを起動する必要はありません。
受け入れるエンコード形式を明確にする
この記事で受け入れるのは、空ではなく、パディングを含まない Base64URL だけです。使用できるのは英字、数字、-、_ で、空白、改行、+、/、= は認めません。これはこのアプリの API で定めるルールであり、Base64 を使うすべてのプロトコルに同じ規則を適用すべきという意味ではありません。
標準 Base64 と Base64URL を、同じ入口で推測して受け入れるべきではありません。旧 API がパディング付きの形式を要求するなら、専用の解析入口を用意し、それぞれを個別にテストします。クライアントの更新によって、受け入れる入力の範囲が知らないうちに広がるのを防ぐためです。
長さにも構造上の制約があります。パディングなしの文字列では、文字数を四で割った余りがゼロ、二、三のいずれかでなければなりません。余りが一になる長さは、有効なエンコードを構成できません。この例では入力上限を UTF-8 で 65536 バイトに設定しますが、これはテスト用のアプリ側ポリシーであり、プロトコルで定められた値ではありません。
デコードで復元できるのはバイト列だけです。内容の信頼性が証明されるわけではありません。トークン、カーソル、署名パラメータに使う場合は、構造、出所、業務上の有効性を別途検証する必要があります。
再エンコードで正規表現を強制する
次のコードを 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
}
最後の比較は省略できません。1 バイトの 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 の契約で統一する必要があります。そうしないと、同じ内容でも処理結果が異なる可能性があります。
回帰テストに組み込み、診断情報の範囲を限定する
独立したスクリプトでテストが通ったら、デコード関数を共有モジュールに置き、サンプルをアプリのユニットテストターゲットに移します。コマンドラインスクリプトは素早い再現に向いており、アプリのテストターゲットは実際の依存関係やビルド設定による差異を検出する役割を担います。長期にわたり、両者で別々の実装を保守してはいけません。
固定サンプルに加え、さまざまな長さのバイト配列に対して「エンコード、デコード、バイト単位の比較」を行えます。特に、長さを三で割った余りが異なるケースを網羅してください。この余りによって、最後のエンコード単位で使われる末尾ビット数が決まるためです。さらに末尾の文字を変更し、非正規の表現が受け入れられないことを確認します。
失敗レポートに残すのは、エラーの種類、入力長、サンプル番号だけにし、実際のトークンやカーソル全体は記録しません。リリース前には、自動トリミングがないこと、未知の文字を無視していないこと、文字集合を混用していないこと、デコード後に構造検証を行っていることを確認します。最終的な目的は、より多くの入力をデコードできるようにすることではありません。API が、双方で合意した唯一の表現だけを受け入れるようにすることです。
よくある質問
デコード後に再エンコードして比較する理由は?
未使用の末尾ビットがゼロでなくても受理するデコーダーがあるためです。正規のパディングなし Base64URL に戻して原文と比較すれば、同じバイト列を表す非正規入力を拒否できます。
この処理でトークンの署名も検証できますか?
できません。検証対象はエンコード形式だけです。署名、有効期限、発行者は、別の暗号処理とアプリケーション側の検証で確認する必要があります。
いつものパイプラインでクラウドMacを検証
ArmRDPでは、仮想マシンではない専有の物理Mac miniをご利用いただけます。1日単位でレンタルできるので、ツールチェーンやビルド結果を確認してから、チームに合った利用期間をお選びください。
クラウドMacをレンタル