クラウド Mac で検証する iOS XML 解析:外部実体・名前空間・資源上限

クラウド Mac で検証する iOS XML 解析:外部実体・名前空間・資源上限

クラウド Mac で検証する iOS XML 解析:外部実体・名前空間・資源上限

ローカルでは読み込める XML でも、無人実行のパイプラインでは処理が止まることがあります。外部実体の宣言、深すぎる要素の入れ子、異なる名前空間に属する同名フィールドなどが原因になり得ます。クラウド Mac で行うべきなのは、こうした入力を「できるだけ修復する」ことではありません。許容範囲を外れた文書を確実に拒否し、再現可能な失敗サンプルを残すことです。

パーサーを呼ぶ前に入力契約を定める

iOS アプリの設定インポート機能を例に、まず受け入れ条件を決めます。文字コードは UTF-8 のみ、DTD は禁止、入力は 1 件あたり最大 256 KiB、要素の深さは最大 16 階層、テキストコールバックで受け取るデータは累計最大 32 KiB とします。これらは本記事で使う上限の例であり、XML 標準の規定でもシステムの既定値でもありません。実際の要件に合わせて調整してください。

構文エラー、上限超過、業務フィールドの検証エラーは分けて記録します。パーサーが成功を返しても、未知のルート要素、単一値フィールドの重複、必須項目の欠落がある文書を受け入れてはいけません。

外部実体の解決を無効にしても、DTD を禁止したことにはならず、リソースを完全に隔離できるわけでもありません。形式上の制約、パーサーの設定、プロセス単位のタイムアウトは、それぞれ個別に検証する必要があります。

クラウド Mac で最小限の安全性チェックを動かす

次のコードを xml-guard.swift として保存します。検査するのは構文とリソースの上限だけで、業務モデルは生成しません。このスクリプトは保守的な方針を採り、コメント内にある DTD 宣言の開始文字列も拒否します。単純な文字列検査を、完全な XML 字句解析と取り違えないようにしてください。

import Foundation

final class GuardDelegate: NSObject, XMLParserDelegate {
    var depth = 0
    var textBytes = 0
    var exceeded = false

    func stop(_ parser: XMLParser) {
        exceeded = true
        parser.abortParsing()
    }

    func parser(
        _ parser: XMLParser,
        didStartElement elementName: String,
        namespaceURI: String?,
        qualifiedName qName: String?,
        attributes attributeDict: [String: String] = [:]
    ) {
        depth += 1
        if depth > 16 { stop(parser) }
    }

    func parser(
        _ parser: XMLParser,
        didEndElement elementName: String,
        namespaceURI: String?,
        qualifiedName qName: String?
    ) {
        depth -= 1
    }

    func parser(_ parser: XMLParser, foundCharacters string: String) {
        textBytes += string.utf8.count
        if textBytes > 32 * 1024 { stop(parser) }
    }

    func parser(_ parser: XMLParser, foundCDATA data: Data) {
        textBytes += data.count
        if textBytes > 32 * 1024 { stop(parser) }
    }

    func parser(
        _ parser: XMLParser,
        resolveExternalEntityName name: String,
        systemID: String?
    ) -> Data? {
        nil
    }
}

func validate(_ path: String) throws -> Bool {
    let handle = try FileHandle(forReadingFrom: URL(fileURLWithPath: path))
    defer { try? handle.close() }

    var data = Data()
    while true {
        guard let chunk = try handle.read(upToCount: 8192),
              !chunk.isEmpty else { break }
        data.append(chunk)
        if data.count > 256 * 1024 { return false }
    }

    guard let source = String(data: data, encoding: .utf8),
          !source.contains("\0"),
          !source.contains("<!DOCTYPE") else {
        return false
    }

    let delegate = GuardDelegate()
    let parser = XMLParser(data: data)
    parser.delegate = delegate
    parser.shouldProcessNamespaces = true
    parser.shouldResolveExternalEntities = false
    parser.externalEntityResolvingPolicy = .never

    let parsed = parser.parse()
    return parsed && !delegate.exceeded && delegate.depth == 0
}

guard CommandLine.arguments.count == 2 else { exit(2) }
do {
    exit(try validate(CommandLine.arguments[1]) ? 0 : 1)
} catch {
    exit(2)
}

使用するツールチェーンを選択済みのターミナルでコンパイルし、正常なサンプルを実行します。

xcrun swiftc xml-guard.swift -o xml-guard
printf '%s\n' '<settings><name>demo</name></settings>' > valid.xml
./xml-guard valid.xml

終了コードは、0 が検査通過、1 が入力の拒否、2 が呼び出しまたはファイル読み取りの失敗です。パイプラインではこのコードで結果を分類し、システムの言語設定で変わるエラーメッセージを解析しないようにします。入力として受け付けるのは、ジョブ自身が一時保存した通常ファイルに限定し、任意のデバイスパスを渡せるようにしてはいけません。

固定サンプルで拒否経路を網羅する

少なくとも、正常な文書、閉じタグのない文書、DTD を含む文書、17 階層の入れ子、テキスト量の超過、入力バイト数の超過という 6 種類のファイルを用意します。次のコマンドで、境界を超えるサンプルを 2 つ生成します。実行環境には Python 3 が必要です。

python3 - <<'PY'
from pathlib import Path

Path("deep.xml").write_text(
    "<n>" * 17 + "</n>" * 17, encoding="utf-8"
)
Path("text-limit.xml").write_text(
    "<n>" + "x" * (32 * 1024 + 1) + "</n>", encoding="utf-8"
)
PY
./xml-guard deep.xml
./xml-guard text-limit.xml

どちらも 1 を返すはずです。比較演算子の誤りを検出するため、上限ちょうどのサンプルと、1 バイトだけ超えるサンプルも対にして用意します。外部実体を含むサンプルは、解析を始める前に拒否されなければなりません。パーサーの外部リソース設定を個別に検証する場合は、管理下にあるテスト対象を使い、実際のアクセスが発生していないことをアサーションで確認します。結果が空だったというだけで、安全だと判断してはいけません。

名前空間とテキストコールバックは構造に沿って扱う

名前空間を有効にした場合、業務フィールドの識別には「名前空間 URI + ローカル名」を使います。接頭辞だけ、あるいは elementName だけで比較してはいけません。接頭辞は変更可能であり、異なる URI に属する name は別の概念を表すことがあります。接頭辞を変更しても受け入れることと、URI を変更したら拒否することの両方をテストしてください。

foundCharacters がフィールド全体を一度に渡す保証はありません。実際のデコーダーでは要素スタックを管理し、テキストをコールバックごとに追加し、要素の終了時に長さ、形式、フィールドの重複を検査します。CDATA は別のコールバックで渡されるため、集計から漏らしてはいけません。また、すべてのフィールドで一律に前後の空白を削除するのも避けます。表示名と、空白を保持する本文とでは、入力契約が異なる可能性があります。

途中までの結果を確定しない

すべてのレコードを、まずジョブ専用の一時モデルに格納します。構文、上限、ルート要素、業務ルールの検査がすべて通った場合に限り、一度だけコミットします。文書の末尾に意図的に不正なタグを追加し、それまでに読み取ったレコードもデータベースへ保存されないことを確認してください。

一度の手動テストで終わらせず、ビルドに検証を組み込む

OSXBuild のクラウド Mac では、まずコマンドラインのサンプルを実行し、続いて同じファイルを iOS のテストターゲットに追加して、アプリが実際に使うデコーダーを呼び出します。両者の入力契約は共通ですが、デスクトップ用スクリプトの成功をもって、シミュレータ上の実装検証に代えることはできません。

回帰テストの記録には、サンプル名、期待する終了コード、実際の結果、ツールチェーンのバージョンを残せば十分です。インポート内容全体をログに出力する必要はありません。異常なジョブはパイプラインのタイムアウトで制限します。コールバック側の上限で制約できるのは、すでに渡されたデータだけであり、パーサー内部のメモリ使用量に絶対的な上限を設けるものではありません。

最後に、正常なファイルをインポートできること、上限を外れたファイルが確実に失敗すること、名前空間をまたいでフィールドを取り違えないこと、失敗時に中途半端なデータが残らないことを一つずつ確認します。こうしておけば、次に解析ロジックを変更したときも、検証の根拠は「今回は止まらなかった」ではなく、再実行できる入力と明確なアサーションになります。

よくある質問

外部実体の解決を無効にすれば十分ですか?

十分ではありません。外部実体の設定は内部 DTD を禁止しません。この記事では DTD を含まない UTF-8 XML に入力を限定し、解析前に保守的な検査を行います。

foundCharacters はフィールド全体を一度に返しますか?

返すとは限りません。同じ要素の文字列が複数回に分かれて届くため、終了要素まで蓄積します。文書全体と業務上の検査が成功してから結果を保存します。

ビルド専用のクラウド Mac を

物理マシン1台をまるごと専有でき、仮想マシンではありません。ビルド量に合わせてマシンと利用期間を選べば、アーカイブ、署名、キャッシュを専用ノードに保管できます。

クラウド Mac のプランを選ぶ

次のアーカイブは、専有クラウド Mac でビルド

ビルド量に合わせてマシンを選び、チームの所在地に合わせてノードを選択。プランを確認してから、ツールチェーンの移行を始めましょう。