クラウド Mac で iOS multipart の符号化と境界を検証する

クラウド Mac で iOS multipart の符号化と境界を検証する

同じ画像がデバッグツールではアップロードできるのに、iOS クライアントでは「ファイルフィールドがありません」と返される場合、まずタイムアウトを変更するのは得策ではありません。アップロードのリクエストボディをクラウド Mac に持ち込み、固定した入力で生のバイト列を確認すれば、フィールド名の不一致なのか、改行の誤りなのか、文字列への変換でファイルの内容が壊れたのかを、たいていはより早く切り分けられます。開発用の Mac には利用可能な Swift ツールチェーンがあれば十分で、本番のアップロードサービスに接続する必要はありません。

まずリクエストボディのバイト仕様を定める

multipart/form-data は、テキストの中にファイルを埋め込むだけの形式ではありません。各パートは境界行、フィールドのヘッダー、空行、内容で構成されます。プロトコル上の改行には CRLF、つまり 0D 0A の 2 バイトを使います。ファイルの内容の後にも CRLF が必要で、最後の境界には末尾にハイフンを 2 つ付けます。

まず API の管理者に、テキストフィールド名、ファイルフィールド名、同名フィールドの繰り返しが許可されるかの 3 点を確認してください。以下ではテキストフィールドを note、ファイルフィールドを file とし、配列の順序どおりにエンコードします。重複キーが失われる辞書は使いません。

検証対象は、最終的に送信する Data です。ログ上で正常に見える文字列ではありません。スクリーンショットでは、バイナリの内容が変わっていないことを証明できません。

テストでは比較しやすいように boundary を固定します。本番のリクエストではランダムな値を生成し、内容と衝突しないことを確認します。リクエストヘッダーの boundary パラメーターには、境界行の先頭に追加する 2 つのハイフンを含めません。

対応範囲を明確にしたエンコーダーを書く

次のコードを multipart-check.swift として保存してください。フィールド名とファイル名には安全な ASCII 文字のみを許可し、ファイルのパートには常に application/octet-stream を使います。これは検証のために意図的に対応範囲を絞った実装であり、国際化されたファイル名に対するサーバー側のすべての処理方式に対応するものではありません。

import Foundation

struct Part {
    let name: String
    let filename: String?
    let bytes: Data
}

enum EncodingError: Error {
    case invalidHeader
    case invalidBoundary
    case boundaryCollision
}

func safeHeader(_ value: String) -> Bool {
    !value.isEmpty && value.utf8.allSatisfy {
        $0 >= 32 && $0 <= 126 && $0 != 34 && $0 != 92
    }
}

func encode(_ parts: [Part], boundary: String) throws -> Data {
    guard (1...70).contains(boundary.utf8.count),
          boundary.utf8.allSatisfy({
              (48...57).contains($0) ||
              (65...90).contains($0) ||
              (97...122).contains($0) || $0 == 45
          }) else {
        throw EncodingError.invalidBoundary
    }

    var body = Data()
    func append(_ text: String) {
        body.append(Data(text.utf8))
    }

    for part in parts {
        guard safeHeader(part.name),
              part.filename.map({ safeHeader($0) }) ?? true else {
            throw EncodingError.invalidHeader
        }
        guard part.bytes.range(of: Data(boundary.utf8)) == nil else {
            throw EncodingError.boundaryCollision
        }

        append("--\(boundary)\r\n")
        var header = "Content-Disposition: form-data; name=\"\(part.name)\""
        if let filename = part.filename {
            header += "; filename=\"\(filename)\""
        }
        append(header + "\r\n")
        append(part.filename == nil
            ? "Content-Type: text/plain; charset=utf-8\r\n"
            : "Content-Type: application/octet-stream\r\n")
        append("\r\n")
        body.append(part.bytes)
        append("\r\n")
    }
    append("--\(boundary)--\r\n")
    return body
}

func expectFailure(_ action: () throws -> Data) {
    do {
        _ = try action()
    } catch {
        return
    }
    fatalError("Expected encoding failure")
}

let boundary = "fixture-boundary"
let binary = Data([0x00, 0xFF, 0x0D, 0x0A, 0x80])
let parts = [
    Part(name: "note", filename: nil, bytes: Data("验收".utf8)),
    Part(name: "file", filename: "sample.bin", bytes: binary)
]
let body = try encode(parts, boundary: boundary)
precondition(body.starts(with: Data("--\(boundary)\r\n".utf8)))
precondition(body.suffix(Data("--\(boundary)--\r\n".utf8).count)
    == Data("--\(boundary)--\r\n".utf8))
let filePrefix = Data(
    ("Content-Disposition: form-data; name=\"file\"; filename=\"sample.bin\"\r\n"
    + "Content-Type: application/octet-stream\r\n\r\n").utf8
)
guard let headerRange = body.range(of: filePrefix) else {
    fatalError("Missing file header")
}
let start = headerRange.upperBound
precondition(body.subdata(in: start..<(start + binary.count)) == binary)
precondition(body.subdata(in: (start + binary.count)..<body.count)
    == Data("\r\n--\(boundary)--\r\n".utf8))

expectFailure {
    try encode([
        Part(name: "file", filename: "bad\r\nInjected: yes", bytes: binary)
    ], boundary: boundary)
}
expectFailure {
    try encode([
        Part(name: "note", filename: nil, bytes: Data(boundary.utf8))
    ], boundary: boundary)
}

このコードでは、ファイルを Data のまま直接追加し、UTF-8 としてデコードしません。ヘッダーの値では制御文字、二重引用符、バックスラッシュを拒否します。これにより、ユーザーが指定したファイル名が引用符の外に抜け出したり、新しいフィールドヘッダーを挿入したりすることを防ぎます。ここでの衝突チェックはプロトコルの要件よりも厳しく、内容に boundary 自体が含まれていれば拒否します。本番環境では、別の値を生成して再試行できます。

クラウド Mac で実行し、サンプルを追加する

まず xcrun swift multipart-check.swift を実行します。正常であれば何も出力されず、終了コードはゼロになります。アサーションの失敗やエンコード時の例外があれば、終了コードはゼロ以外になります。この検証スクリプトは、アサーションを無効にする最適化モードで実行しないでください。

現在のバイナリサンプルには、意図的にゼロバイト、不正な UTF-8、CRLF を含めています。アサーションではファイルの内容を検索するだけでなく、ファイルの直後に終了の境界行が続くことも確認します。「バイトは存在するが、位置が違う」という状態を合格にしないためです。

失敗ケースと境界値の入力を 4 組追加する

空のファイル、空のテキスト、同名のテキストフィールド 2 つ、名前に二重引用符を含むパートを追加します。内容が空でも、ヘッダーの後の空行と内容の後の CRLF は残す必要があります。重複するフィールドは、個数と順序を維持しなければなりません。さらに中国語のファイル名を使ったサンプルも追加し、期待する結果を、黙って名前を変更することではなく、明確に拒否することとします。

アプリケーションの要件として中国語のファイル名を受け付ける必要がある場合は、まずサーバーが対応するエンコード方式を確認してから実装を拡張してください。ファイル名をパーセントエンコードすれば汎用的に解決できると決めつけたり、ヘッダーインジェクションのチェックを省いてすべての Unicode 文字を許可したりしてはいけません。

URLSession への組み込みでも同じデータを使う

本番への組み込みでは、boundary とエンコード済みの Data をセットでリクエスト構築層に渡します。Content-Type には multipart/form-data; boundary= に実際の値を続けたものを指定し、同じ Data をアップロードタスクに渡します。2 つの層で境界を別々に生成してはいけません。

小さなサンプルはメモリ上でエンコードできます。大きなファイルでは、リクエストボディを一時ファイルに分割して書き込み、そのファイルからアップロードする方式を使います。これにより、元のファイルとリクエストボディ全体の両方を同時にメモリへ保持することを避けられます。ファイルへのエンコードに切り替えた後も、完成したファイルに対して同じバイト検証を再実行してください。

アプリケーション側で Content-Length を設定する場合は、最終的なリクエストボディのバイト数を使う必要があります。文字数や元のファイルサイズではありません。手動設定が必要かどうかはネットワーク層で一元的に判断し、エンコーダー側で不正確になり得る別の長さを生成しないようにします。

プロトコルの検証と API の検証を分ける

ローカルでのバイト検証に合格しても、証明できるのは、このエンコード仕様に適合していることだけです。次にテスト用 API でリクエストを解析し、フィールドの値、ファイルの長さ、ファイルのダイジェストを照合します。そのうえで、重複フィールド、空のファイル、ファイル名に対するサーバーの実際の動作を確認します。

失敗が見つかった場合は、boundary、バイト数、合成したサンプルだけを保存し、実際のユーザーがアップロードした内容は保管しません。診断の順序は、まず区切り、次にフィールドのヘッダー、最後にファイルのバイト列の比較と固定します。ファイルサイズの上限超過や API によるファイル形式の拒否であれば、サーバー側の取り決めに立ち戻ってください。改行を変更して業務上のエラーを取り繕ってはいけません。

最後に、このスクリプトをコミット時のチェックに組み込み、テストレポートにツールチェーンのバージョンと終了コードを記録します。これにより、アップロードモジュールの実装を変更しても、たまたま一度成功したネットワークリクエストではなく、同じバイト仕様を基準に検証できます。

よくある質問

本番でも固定の boundary を使うべきですか?

いいえ。固定値は再現可能なテスト専用です。本番ではランダムな境界を生成し、各パートの内容に含まれていないことを確認して、ヘッダーと本文に同じ値を使います。

本文全体を文字列に変換して検証できますか?

バイナリの検証には不適切です。ファイルのバイト列が有効な UTF-8 とは限らないため、Data を直接比較します。文字列表示はヘッダーと区切りの確認に限定してください。

開発用に、専用のMacを1台確保

Mac miniの物理ノードを、仮想マシンではなく専有で利用できます。日単位・週単位・月単位・四半期単位から選び、用途と構成を決めたら、コントロールパネルに表示される最新の空き状況をご確認ください。

機種と利用期間を選ぶ 料金一覧を見る