PDFium ComponentはTPdfParserResourceBudgetを公開しています。これは、ライブラリ内のすべてのPDFパーサに対して、再帰の深さ、トークン数、オブジェクト数、デコード済みバイト数、展開比率、アロケーション、ウォールクロックの期限を制限する単一のレコードです。ConfigurePdfParserResourceBudgetはそれをプロセス単位でアトミックに設定し、各パースはスナップショットをコピーするため、操作の途中でしきい値が動くことはありません
6つのパーサ、6つのプライベートな天井
これを強制した状況は、ありふれていて、おそらく見覚えのあるものです。スパースなxref走査には独自のオブジェクト上限がありました。ストリームインスペクタには独自のデコードバイト上限がありました。XMPには1つ、XFDFとFDFには別のもの、コンテンツ正規化には3つ目、プリフライトのデフォルトには4つ目がありました。これらの数値はどれも単独では妥当で、どれも外部からは到達できませんでした。そのため、「この取り込み経路は匿名アップロードを扱う。ここでは64 MiBを超えてアロケートしてはならないし、4秒を超えて実行してもいけない」と言いたい呼び出し元に、それを言う場所がありませんでした。2つ目の問題は沈黙するがゆえにさらに悪く、制限が可変グローバルに住んでいるところでは、あるポリシーの下で始まったパースが別のポリシーの下で終わる可能性がありました。あるスレッドがセッターを呼んでいる間に別のスレッドがオブジェクトストリームの途中にいて、走査の前半は古い天井で制限され、後半は新しい天井で制限されるのです。何もクラッシュせず、何もログされず、後からその操作が実際にどの制限の下で走ったのか言えません。それはまさに、制限が必要だった理由の性質です
TPdfParserResourceBudgetが実際に制限するもの
他の次元が通す入力のクラスをそれぞれ1つずつ捕捉する、という理由で選ばれた7つの次元です。MaxRecursionDepthはデフォルト1024で、入れ子の辞書と配列がISO 32000-1 §7.3のオブジェクト構文をスタックオーバーフローに変えるのを防ぎます。MaxTokensはデフォルト10,000,000で、ファイルサイズとは独立に字句解析の作業を制限します。MaxObjectsはデフォルト4,000,000で、クロスリファレンスのサブセクション数、オブジェクトストリームの/N値、グラフ走査をカバーします。MaxDecodedBytesはデフォルト256 MiB、MaxAllocationBytesは512 MiBで、ファイルはアロケーションの後ではなく前に拒否できます。MaxExpansionRatioはデフォルト1000.0で、人々が過小評価するのがこれです。ISO 32000-1 §7.4のストリームフィルタは汎用圧縮であり、数キロバイトのFlateデータが正当に数百メガバイトに展開できます。比率の天井は、解凍爆弾に対する標準的な防御です。これは絶対バイト上限とは直交します。200 KiBのストリームが200 MiBになろうとするのは、256 MiBの天井の下でありながら依然として馬鹿げているからです。DeadlineMillisecondsはデフォルト0で、期限なしを意味し、この次元は厳密にオプトインです。デフォルトはセットとして意図的に寛大で、堅牢化されたプロファイルではなく、普通の業務文書が何かをトリップしないサイズです。入力が公開アップロードフォームから来るなら、DelphiのPDFプレビューペインの堅牢化で説明されているルールに加えて、下記の数値に近いものを望むでしょう
uses
FPdfPdfCommon;
procedure ApplyUntrustedIntakeBudget;
var
Budget: TPdfParserResourceBudget;
begin
Budget := TPdfParserResourceBudget.Default;
// デフォルト維持:MaxRecursionDepth 1024、MaxTokens 10000000、
// MaxObjects 4000000
Budget.MaxDecodedBytes := Int64(32) * 1024 * 1024; // デフォルト256 MiB
Budget.MaxAllocationBytes := Int64(64) * 1024 * 1024; // デフォルト512 MiB
Budget.MaxExpansionRatio := 50.0; // デフォルト1000.0
Budget.DeadlineMilliseconds := 4000; // デフォルト0 = オフ
ConfigurePdfParserResourceBudget(Budget);
end;
グローバルを読む代わりにスナップショットを取る理由
適用されている最中に変わりうる制限は、制限ではないからです。これはこの機能全体の中核的な設計ポイントであり、すべてのエントリポイントで1行の規律です。操作の開始時に一度ポリシーをローカルのTPdfParserResourceBudgetに読み込み、その後すべてのチェックポイントがそのローカルコピーと比較します。GetPdfParserResourceBudgetはクリティカルセクションを取り、レコードの値コピーを返します。その後、パースは可変グローバル状態に二度と触れません。その結果、別のスレッドからのConfigurePdfParserResourceBudget呼び出しは、次の操作にだけ影響し、実行中の操作には決して影響しません。並行パースは干渉せずに異なるポリシーの下で実行され、リソース制限で失敗した操作は単一の一貫したしきい値セットに対して失敗し、ホットパスにロック競合はありません。ロックがトークンごとではなくパースごとに1回取られるからです
type
TCustomScanState = record
Budget: TPdfParserResourceBudget;
StartTick: Cardinal;
TokenCount: Int64;
Depth: Integer;
end;
procedure BeginScan(var S: TCustomScanState);
begin
S.Budget := GetPdfParserResourceBudget; // スナップショットは最初に1回だけ取る
S.StartTick := PdfParserStartTick;
S.TokenCount := 0;
S.Depth := 0;
end;
function ScanCheckpoint(var S: TCustomScanState): Boolean;
begin
Result := False;
if PdfParserDeadlineReached(S.StartTick, S.Budget) then
Exit;
Inc(S.TokenCount);
if S.TokenCount > S.Budget.MaxTokens then
Exit;
if S.Depth > S.Budget.MaxRecursionDepth then
Exit;
Result := True;
end;
ライブラリの上に自分のパーサを書くなら、トークンループの中でGetPdfParserResourceBudgetを呼ぶのではなく、この形をコピーしましょう。トークンごとに呼ぶのは正しいけれど無意味です。毎回の反復でクリティカルセクションを支払い、スナップショットが防ぐはずだった操作途中のドリフトを再導入します
バイトの天井が病的なファイルを止められない理由
PDFではサイズとコストの相関がゆるいからです。ISO 32000-1 §7.5.8のクロスリファレンスストリームで/Indexと/W配列がもっともらしいが敵対的なエントリ数を記述する400 KiBのファイル、同じオフセットを再訪するオブジェクトストリームチェーン、あるいは見た目よりコストがかかるオペレータのコンテンツストリームは、設定したすべてのバイトとオブジェクトの天井の下に余裕で収まりながら、ワーカースレッドを数分間占有します。バイト数はメモリを制限します。時間は制限しません。それがPdfParserStartTickとPdfParserDeadlineReachedの役目です。PdfParserStartTickは操作の開始時にティック値を捕捉し、PdfParserDeadlineReachedは経過時間をDeadlineMillisecondsと比較して、期限が0のときは常にFalseを返します。減算は符号なし演算で実行されるため、ティックカウンタのロールオーバーが偽のタイムアウトを生むことはありません。クロスリファレンス解析、オブジェクトストリームのデコード、コンテンツトークンの正規化、FDFとXFDFのリーダーはすべてループの頭でこれをチェックするため、予算超過のパースは外部から殺されるのではなくチェックポイントで停止します
正直な注意が2つあります。これはウォールクロック時間でありCPU時間ではないため、スワップするマシンや負荷のかかったビルドサーバーは、静かなマシンならクリアする期限をトリップさせることがあります。これは、中央値に近づけるのではなく、観測された最悪ケースを十分に上回る設定を支持します。そしてデフォルトではオフです。月末のバッチ処理中に正当に大きな文書で期限が発火するのは、それが防ぐ失敗よりも悪い失敗だからです。グローバルではなく取り込み経路ごとにオプトインし、クロスリファレンスストリームの検証の構造的チェックと組み合わせてください
予算は天井であり、フォーマット制限の代わりではない
各パーサは独自のフォーマット固有定数を保持し、両方を適用します。XFDFが最も明確な例です。フィールドリーダーは、スナップショット値に加えてXfdfMaxTopLevelFieldsと100000のMaxFieldNodes定数を強制し、どちらか一方がパースを停止できます
const
XfdfMaxTopLevelFields = 100000; // フォーマットローカル、常に適用
...
// 両方の天井が有効。低い方が勝つ
if (FieldCount >= XfdfMaxTopLevelFields) or
(FieldCount >= Budget.MaxObjects) then
Exit(False);
これは意図的で、存在しないつまみを探しに行く前に心に留める価値があります。4,000,000オブジェクトのグローバル予算は、XFDFファイルに4,000,000フィールドを許可しません。100000フィールドは、実際の注釈エクスポートが生成するものの範囲をすでに大きく超えているからです。グローバルな天井を上げることは、フォーマット固有の健全性チェックを静かに広げるべきではありません。コンテンツ正規化も逆方向から同じように機能します。TPdfContentNormalizeOptions.Defaultは独自のページ単位の制限から始め、それぞれをスナップショットまでクランプするため、MaxTokensPerPageは予算によって下げられるだけで、決して上げられません。グローバルポリシーは締める方向にのみ働きます。緩むことはありません
悪い設定は、設定した時点で失敗する
ConfigurePdfParserResourceBudgetは代入する前に検証します。6つの数値次元のどれか1つでも非正の値なら即座に例外を上げ、High(Integer)を超えるMaxDecodedBytesは、デコーダの奥で静かに切り詰められるのではなく、プラットフォームの制限を超えるとして拒否されます。これは聞こえる以上に重要です。キーが欠けた設定ファイルから作られた予算はゼロを与え、起動時に受け入れられたゼロのオブジェクト天井は、3時間後の最初の文書で不可解なパース失敗に変わり、その時点では誰も設定を疑いません。同じ注意が、テストを含め、予算を一時的に締めるすべてのコードに当てはまります。ポリシーはプロセス全体なので、フィクスチャが終わった後も4秒の期限を設置したままにすると、それ以降のすべてを毒します。変更の前後でセーブ&リストアしましょう
var
Saved: TPdfParserResourceBudget;
begin
Saved := GetPdfParserResourceBudget;
try
ApplyUntrustedIntakeBudget;
ProcessInboundDocument(FileName);
finally
ConfigurePdfParserResourceBudget(Saved);
end;
end;
ResetPdfParserResourceBudgetはより乱暴なバージョンで、TPdfParserResourceBudget.Defaultをそのまま復元します。既知の状態を望むテストのティアダウンでこれを使い、本番コードではセーブ&リストアを使いましょう。本番では、ホストアプリケーションが起動時に設定したものが、戻らなければならない状態です
予算が役に立たなくなる場所
これは単一のプロセス全体ポリシーであり、それが主な制限です。1つのワーカースレッドに厳格なプロファイルを、別のスレッドに寛容なプロファイルを与えることはできません。1つのクリティカルセクションの背後に1つのレコードしかないからです。テナントごとの制限が本当に必要なら、機能する形は各ポリシークラスをそれぞれのプロセスに直列化するか、セーブ&リストアで作業を括って、他の何も並行してパースしていないときだけ括りが安全だと受け入れることです。また、パースを制限するものであり、レンダリングは制限しません。ラスタライズのメモリ、フォントキャッシュの成長、画像デコードはこの7つの次元の外にあり、独自の制御が必要です。そして予算は、操作が高すぎたと教えてくれますが、なぜかは教えてくれません。発火した天井は症状であり、背後にある不正な/Index配列や暴走するフィルタチェーンは依然として手で診断しなければなりません。オブジェクトストリームとプレディクタのデコードで説明されているデコード経路が、数字を動かし始める前に理解する価値があるのはこのためです。予算を、悪い文書がプロセスを道連れにするのを防ぐものとして扱い、文書が何をしていたのかを突き止められるだけのログを残しましょう
完全なパーサAPIサーフェス、予算レコード、Delphi、C++Builder、Lazarus用のソースコードビルドは製品ページにあります:PDFium Component