[PHP]xml_set_unparsed_entity_decl_handlerとは?非解析対象実体宣言を検知する方法を徹底解説

PHP

はじめに

前回の記事では、DTDの記法宣言(<!NOTATION>)を検知する xml_set_notation_decl_handler() を解説し、その中で「非解析対象実体(unparsed entity)」という関連する概念に触れました。今回は、その非解析対象実体の宣言そのものを検知するための関数、xml_set_unparsed_entity_decl_handler() を正面から取り上げます。

非解析対象実体とは、<!ENTITY logo SYSTEM "logo.jpg" NDATA jpeg> のように、XMLパーサーによる構文解析の対象外となる外部のバイナリデータ(画像ファイルなど)を、DTD内で名前付きの実体として宣言する仕組みです。これは前回解説した記法宣言(NOTATION)とセットで使われることが前提の、やや専門性の高い機能です。本記事では、この関数の役割と、実践的な活用パターン、そしてセキュリティ上の注意点まで詳しく解説します。


関数概要

項目内容
関数名xml_set_unparsed_entity_decl_handler()
所属拡張XML Parser拡張(Expatベース、標準で有効)
シグネチャxml_set_unparsed_entity_decl_handler(XMLParser $parser, ?callable $handler): true
引数1$parserxml_parser_create()等で生成したパーサーインスタンス
引数2$handler — 非解析対象実体宣言検知時に呼び出すコールバック(nullで解除)
コールバックの引数(XMLParser $parser, string $entityName, string $base, string $systemId, string $publicId, string $notationName)
戻り値常に true
対応バージョンPHP 4以降
関連する仕組み記法宣言(xml_set_notation_decl_handler())とセットで使われる

非解析対象実体の全体像(イメージ図)

  DTDを含むXML文書
  <!DOCTYPE root [
    <!NOTATION jpeg SYSTEM "image/jpeg">
    <!ENTITY logo SYSTEM "logo.jpg" NDATA jpeg>
  ]>
  <root/>
              │
              ▼
        xml_parse()による解析
              │
   ┌──────────┴──────────────────────┐
   ▼                                    ▼
  "<!NOTATION jpeg ...>" を検知          "<!ENTITY logo ... NDATA jpeg>" を検知
   │                                    │
   ▼                                    ▼
  xml_set_notation_decl_handler()       xml_set_unparsed_entity_decl_handler()
  (前回記事で解説)                       ★この記事の対象
                                        entityName="logo"
                                        systemId="logo.jpg"
                                        notationName="jpeg"

ポイントは、非解析対象実体宣言には必ずNDATAキーワードとともに記法名(notation名)が指定されているという点です。これにより、「このエンティティが参照する外部データは、どの記法(データ形式)として解釈すべきか」という情報が明示されます。コールバックの最後の引数 $notationName には、この記法名が渡されます。


実践サンプル7選

例1:基本的な使い方

<?php

class BasicUnparsedEntityDemo
{
    public function detectEntities(string $xml): array
    {
        $entities = [];
        $parser = xml_parser_create();

        xml_set_unparsed_entity_decl_handler(
            $parser,
            function ($p, $entityName, $base, $systemId, $publicId, $notationName) use (&$entities) {
                $entities[] = [
                    'name'     => $entityName,
                    'system'   => $systemId,
                    'notation' => $notationName,
                ];
            }
        );

        xml_parse($parser, $xml, true);
        xml_parser_free($parser);

        return $entities;
    }
}

$demo = new BasicUnparsedEntityDemo();
$xml = '<!DOCTYPE root [<!NOTATION jpeg SYSTEM "image/jpeg"><!ENTITY logo SYSTEM "logo.jpg" NDATA jpeg>]><root/>';
print_r($demo->detectEntities($xml));

例2:記法宣言と非解析対象実体を紐付けて一覧化するクラス

<?php

class NotationEntityLinker
{
    private array $notations = [];
    private array $entities = [];

    /**
     * 記法宣言と非解析対象実体宣言の両方を検知し、
     * どのエンティティがどの記法を参照しているかを紐付ける
     */
    public function link(string $xml): array
    {
        $this->notations = [];
        $this->entities = [];
        $parser = xml_parser_create();

        xml_set_notation_decl_handler(
            $parser,
            function ($p, $notationName, $base, $systemId) {
                $this->notations[$notationName] = $systemId;
            }
        );

        xml_set_unparsed_entity_decl_handler(
            $parser,
            function ($p, $entityName, $base, $systemId, $publicId, $notationName) {
                $this->entities[$entityName] = [
                    'system_id'      => $systemId,
                    'notation_name'  => $notationName,
                    'notation_system' => $this->notations[$notationName] ?? '(未定義)',
                ];
            }
        );

        xml_parse($parser, $xml, true);
        xml_parser_free($parser);

        return $this->entities;
    }
}

$linker = new NotationEntityLinker();
$xml = '<!DOCTYPE root [
  <!NOTATION jpeg SYSTEM "image/jpeg">
  <!ENTITY logo SYSTEM "logo.jpg" NDATA jpeg>
]><root/>';
print_r($linker->link($xml));

例3:許可された記法を参照するエンティティのみを受け入れるバリデーター

<?php

class AllowedEntityFormatValidator
{
    private array $allowedNotations;
    private array $violations = [];

    public function __construct(array $allowedNotations)
    {
        $this->allowedNotations = $allowedNotations;
    }

    /**
     * 非解析対象実体が参照している記法が、
     * 許可されたリストに含まれるかどうかを検証する
     */
    public function validate(string $xml): array
    {
        $this->violations = [];
        $parser = xml_parser_create();

        xml_set_unparsed_entity_decl_handler(
            $parser,
            function ($p, $entityName, $base, $systemId, $publicId, $notationName) {
                if (!in_array($notationName, $this->allowedNotations, true)) {
                    $this->violations[] = "エンティティ'{$entityName}'は許可されていない記法'{$notationName}'を参照しています";
                }
            }
        );

        xml_parse($parser, $xml, true);
        xml_parser_free($parser);

        return $this->violations;
    }
}

$validator = new AllowedEntityFormatValidator(['jpeg', 'png']);
$xml = '<!DOCTYPE root [<!ENTITY exe SYSTEM "malware.exe" NDATA executable>]><root/>';
print_r($validator->validate($xml));

例4:外部システムIDのセキュリティチェックツール

<?php

class ExternalReferenceSecurityScanner
{
    /**
     * 非解析対象実体が参照する外部システムIDに、
     * 不審なプロトコル(file://など)が使われていないか確認する
     */
    public function scanForRisks(string $xml): array
    {
        $risks = [];
        $parser = xml_parser_create();

        xml_set_unparsed_entity_decl_handler(
            $parser,
            function ($p, $entityName, $base, $systemId) use (&$risks) {
                if (str_starts_with($systemId, 'file://') || str_starts_with($systemId, 'php://')) {
                    $risks[] = "潜在的に危険な参照: {$entityName} => {$systemId}";
                }
            }
        );

        xml_parse($parser, $xml, true);
        xml_parser_free($parser);

        return $risks;
    }
}

$scanner = new ExternalReferenceSecurityScanner();
$xml = '<!DOCTYPE root [<!ENTITY secret SYSTEM "file:///etc/passwd" NDATA jpeg>]><root/>';
print_r($scanner->scanForRisks($xml));

例5:PUBLIC識別子を含む宣言の情報を取得するクラス

<?php

class PublicIdentifierExtractor
{
    /**
     * SYSTEM識別子だけでなくPUBLIC識別子も持つ
     * 非解析対象実体宣言の情報を取得する
     */
    public function extract(string $xml): array
    {
        $result = [];
        $parser = xml_parser_create();

        xml_set_unparsed_entity_decl_handler(
            $parser,
            function ($p, $entityName, $base, $systemId, $publicId, $notationName) use (&$result) {
                $result[] = [
                    'entity'    => $entityName,
                    'public_id' => $publicId,
                    'system_id' => $systemId,
                ];
            }
        );

        xml_parse($parser, $xml, true);
        xml_parser_free($parser);

        return $result;
    }
}

$extractor = new PublicIdentifierExtractor();
$xml = '<!DOCTYPE root [<!NOTATION tex SYSTEM "app/tex"><!ENTITY doc PUBLIC "-//Example//TEX//EN" "doc.tex" NDATA tex>]><root/>';
print_r($extractor->extract($xml));

例6:レガシーDTDフォーマットの使用状況を統計化するツール

<?php

class LegacyDtdUsageStatistics
{
    /**
     * 複数のXMLファイルを解析し、
     * 非解析対象実体がどの程度使われているかを集計する
     */
    public function collectStatistics(array $filePaths): array
    {
        $stats = ['total_files' => count($filePaths), 'files_with_unparsed_entities' => 0, 'total_entities' => 0];

        foreach ($filePaths as $path) {
            $content = file_get_contents($path);
            $parser = xml_parser_create();
            $countInFile = 0;

            xml_set_unparsed_entity_decl_handler(
                $parser,
                function () use (&$countInFile) {
                    $countInFile++;
                }
            );

            xml_parse($parser, $content, true);
            xml_parser_free($parser);

            if ($countInFile > 0) {
                $stats['files_with_unparsed_entities']++;
                $stats['total_entities'] += $countInFile;
            }
        }

        return $stats;
    }
}

// $statistics = new LegacyDtdUsageStatistics();
// print_r($statistics->collectStatistics(['/tmp/a.xml', '/tmp/b.xml']));

例7:現代的な代替アプローチとの比較(属性ベースのバイナリ参照)

<?php

class ModernBinaryReferenceApproach
{
    /**
     * 現代のXML設計では、非解析対象実体の代わりに
     * 属性でファイルパスやMIMEタイプを明示するのが一般的
     */
    public function buildModernReference(string $entityName, string $filePath, string $mimeType): string
    {
        $xml = new SimpleXMLElement('<resource/>');
        $xml->addAttribute('name', $entityName);
        $xml->addAttribute('src', $filePath);
        $xml->addAttribute('type', $mimeType);

        return $xml->asXML();
    }
}

$approach = new ModernBinaryReferenceApproach();
echo $approach->buildModernReference('logo', 'assets/logo.jpg', 'image/jpeg');

関連関数との比較

関数役割xml_set_unparsed_entity_decl_handlerとの違い
xml_set_unparsed_entity_decl_handler()非解析対象実体宣言(NDATA付きENTITY)を検知本記事の対象。記法を「使用する」側の宣言
xml_set_notation_decl_handler()記法宣言(<!NOTATION>)を検知記法そのものを「定義する」側の宣言、対になる関数
xml_set_external_entity_ref_handler()外部実体参照を検知・制御(セキュリティ重要)解析対象の外部実体(テキストとして展開されるもの)を扱う点が異なる
xml_set_default_handler()未処理データ全般を捕捉非解析対象実体宣言に特化しておらず、より汎用的
DOMDocument::entitiesDOM方式で実体情報にアクセスするSAX方式ではなくDOMツリー経由でのアクセス

よくある落とし穴(注意点)

  1. 現代のXML文書ではほとんど使われない機能である 非解析対象実体は、記法宣言と同様にXML 1.0の古い仕様に由来する機能です。現代のシステムでは、バイナリデータの参照にはBase64エンコードや単純な属性によるパス指定が一般的であり、この関数を使う機会は非常に限定的です。
  2. xml_set_notation_decl_handler() との役割分担を混同する 記法「そのものの定義」を検知するのは xml_set_notation_decl_handler() であり、その記法を「使用する」実体宣言を検知するのが本記事の xml_set_unparsed_entity_decl_handler() です。この2つはセットで登場することが多いため、役割を混同しないようにしましょう(例2を参照)。
  3. 記法が先に定義されているとは限らない DTD内での記述順序によっては、実体宣言が記法宣言よりも先に処理される可能性があります。両方の情報を紐付けたい場合、片方が未定義の状態も考慮した実装が必要です(例2の?? '(未定義)'のような処理)。
  4. 外部システムIDへの参照はセキュリティリスクを伴う可能性がある SYSTEM 識別子には任意のURIを指定できるため、外部から受け取るXMLにこの宣言が含まれる場合、意図しないファイルパスやプロトコルが指定されていないかを確認することが重要です(例4を参照)。ただし、xml_set_external_entity_ref_handler() が扱う「解析対象の外部実体参照」ほど直接的なリスクではない点も理解しておきましょう。
  5. xml_set_external_entity_ref_handler()との対象範囲の違いを混同する 非解析対象実体(NDATA付き)は、XMLパーサーによってテキストとして展開されることのない、いわば「参照情報の宣言」にとどまります。実際にテキストとして展開される一般的な外部実体参照とは異なる仕組みである点に注意しましょう。

まとめ

観点まとめ
何をする関数かDTD内の非解析対象実体宣言(NDATA付きENTITY)を検知するコールバックを登録する
主な用途レガシーなDTDベースの文書における、外部バイナリデータ参照の把握
セットで使う関数xml_set_notation_decl_handler()(参照先の記法定義を検知)
現代における位置づけ使用機会は非常に限定的。主に古いフォーマットとの互換性維持が目的
注意点記法宣言との役割の混同、宣言順序への依存、外部参照のセキュリティ確認

xml_set_unparsed_entity_decl_handler() は、記法宣言と組み合わさることで、XML 1.0の古い仕組みにおける外部バイナリデータの参照情報を検知するための、専門性の高い関数です。現代の開発で新たに採用する機会は限られますが、レガシーなDTDベースの文書を扱う際には、xml_set_notation_decl_handler() とセットでその役割を理解しておくことが役立ちます。

タイトルとURLをコピーしました