💡 完全に動作するサンプルは GitHub で入手可能です:
load-untrusted-documents-safely-python

古い方法は苦痛だった

アップロードされたドキュメントのサムネイルを描画するために 3 行のコードを書きました。以下のように見え、問題なさそうに見えました。

with signature.Signature(upload_path) as sign:
    save_page_preview(sign, thumbnail_path)

GroupDocs.Signature 26.9 以前では、これらの行はドキュメントが指すすべてのアドレスにアクセスしていました。Word ファイルは、実際に含まれていない画像への URL を保持できます。ファイルは URL を保存し、開く側がその URL をダウンロードします。デスクトップ環境では機能ですが、アップロードを受け付けるサーバーでは、ファイルを送ってきた相手がインフラストラクチャに対してどのアドレスへリクエストを送るかを決定できてしまいます。

この攻撃は サーバーサイドリクエストフォージェリ (SSRF) と呼ばれ、次の 3 つの形があります。インターネットからは到達できない内部アドレスでもサーバーからは到達可能なので、細工されたドキュメントは http://169.254.169.254/ や localhost の管理エンドポイントへリクエストさせられます。UNC パスは Windows ホストに対して外部認証を促し、攻撃者が管理するサーバーに資格情報を渡すことがあります。また、応答しないホストへのリンクはロードスレッドをタイムアウトまで待たせ、無害に見えるドキュメントでワーカープールを枯渇させる安価な手段となります。

これはドキュメントライブラリのバグではありません。リンク先へアクセスするのはフォーマットが要求する動作です。問題なのは、コードレビューで指摘されないままデフォルトで実行されていた点です。

より良い方法があります

安全なドキュメント読み込みは、Python 用 GroupDocs.Signature がこれらのリクエストを行わない動作です。バージョン 26.9 以降、LoadOptions.skip_external_resources のデフォルトは True になり、同じ 3 行のコードは何も取得せず、リンクされた画像の代わりにプレースホルダーを描画します。

この変更は新機能というよりデフォルト設定の変更です。プロパティ自体は以前から存在していましたが、26.9 でコードが何も指定しなかった場合の挙動が変わりました。ほとんどのサービスが使用する唯一の設定です。

新しい方法:3 つのロードモード

ステップ1 - 信頼できないものはデフォルトを維持

LoadOptions を全く使用しません:

with signature.Signature(source_path) as sign:
    return save_page_preview(sign, preview_path)

何もリクエストされません。プレビューは本来より小さくなりますが、このサイズ差が「機械から外部へのリクエストが一切行われていない」ことを示す最も手軽な証拠です。

ステップ2 - 実際に所有しているホストをホワイトリストに追加

多くのドキュメントは正当な場所(社内 CDN、内部画像サーバー、テンプレートストアなど)へリンクしています。そのホストだけを許可し、他はブロックします:

load_options = LoadOptions()
load_options.whitelisted_resources = [trusted_address]

with signature.Signature(source_path, load_options) as sign:
    return save_page_preview(sign, preview_path)

マッチングルールに注意が必要です。リソースアドレスに対する大文字小文字を区別しない部分文字列テストなので、短いフラグメントは危険です。たとえば github は github.attacker.example/payload.png にもマッチします。スキーム、ホスト、パスを指定してください。サンプルでは raw.githubusercontent.com/groupdocs-signature/ をホワイトリストにしています。

ステップ3 - すべて許可、意図的に

26.9 以前の動作は依然として利用可能です:

load_options = LoadOptions()
load_options.skip_external_resources = False

自前のアプリケーションが生成したドキュメントには合理的です。ただし注意点があります。廃止予定の load_external_resources プロパティは逆の意味を持つため、skip_external_resources = False は load_external_resources = True の代わりになります。古いプロパティから値をコピーすると、エラーなしでセキュリティ姿勢が逆転してしまいます。

サイドバイサイド:変更前 vs. 変更後

同一ドキュメント、同一コードパス、3 つのロードポリシーです。以下はサンプルの Result/ フォルダーにコミットされたファイルサイズで、実際に確認できます。

ロードモード プレビューサイズ 発信リクエスト
デフォルト (26.9 以降) 16,435 バイト なし
ホワイトリスト対象ホスト 51,738 バイト 許可されたアドレスへ 1 回
すべてのリソース (26.9 以前のデフォルト) 51,738 バイト リンクされたリソースごとに 1 回

リンクされた画像は差分の 35,303 バイトを占めています。この 2 つの数値を並べてみるまで設定を信用できませんでした。プロパティを読み返すと、実際に構成した内容が分かります。

外部リソースとは何か?

想像よりも範囲は狭く、アップグレードがほとんど影響を与えない理由です。対象は リンクされた画像(埋め込みではない)、INCLUDEPICTURE フィールド、プレゼンテーションやスプレッドシートのリンク画像、SVG が参照する画像やスタイルシートです。埋め込みコンテンツはファイル内部にあるため、リクエストは不要です。

この区別がセキュリティ境界全体です。ドキュメントがバイト列ではなくアドレスを保持している場合にのみサーバーが外部へアクセスします。したがって、コーパス全体で「リンクしているファイルがどれだけあるか」を調べれば、何もリンクしていなければ新しいデフォルトはコストゼロで、追加の調査なしにアップグレードできます。

実例:署名されるアップロード

デフォルト変更が想定されたシナリオです。外部からドキュメントが届き、そこに署名を付与します:

with signature.Signature(source_path) as sign:
    options = QrCodeSignOptions("Approved by GroupDocs.Signature")
    options.encode_type = QrCodeTypes.QR
    options.left = 400
    options.top = 50
    options.width = 120
    options.height = 120

    result = sign.sign(output_path, options)

ドキュメントの読み込み、署名、保存のいずれの段階でも外部リソースは要求されません。署名済みの出力はリンクを保持するため、後で Word で開くユーザーは自分のマシンで画像が解決されます。スキップはサーバー側のポリシーであり、ドキュメント自体の編集ではありません。これが他者のファイルを扱う際に安全に適用できる理由です。

アップグレード時に他に変わることは?

ほとんどのサービスでは目に見える変化はありません。これは、セキュリティデフォルトが全体の挙動を変えるとアップグレードレビューで通らないためです。署名、検証、検索はそのままです。例外は、リンク画像を表示していたプレビューがプレースホルダーに変わる点です。自分のホストであればホワイトリストに追加し、そうでなければそのまま受け入れます。

別途指摘すべきは SVG です。SVG は URL で画像やスタイルシートを参照でき、これらも同じ外部リソース扱いです。SVG はアップロード形式としても SSRF ベクトルとしても一般的です。SVG アバターを受け取りサーバー側でレンダリングするサービスは、本変更が保護する典型的なケースです。

Python の細かい点:プレビューの書き込み方法

PreviewOptions はパスではなく 2 つのストリームファクトリを受け取ります。普通の Python 呼び出し可能オブジェクトで構いません:

def create_page_stream(page_data):
    return open(preview_path, "wb")

def release_page_stream(page_data, page_stream):
    page_stream.close()

preview_options = PreviewOptions(create_page_stream, release_page_stream)
preview_options.preview_format = PreviewOptions.PreviewFormats.PNG
sign.generate_preview(preview_options)

1 ページごとにストリームを作成し、もう一方で解放します。サンプルドキュメントは 1 ページだけなので 1 ファイルが書き込まれます。複数ページの場合はページ番号をファイル名に含めるか、各ページが前のページを上書きしないようにしてください。

結論

デフォルトが反転し、リスクのある動作は明示的な決定が必要になり、安全な動作は何もしなくても適用されます。信頼できない入力はデフォルトのままにし、独自ホストは狭くホワイトリスト化し、署名処理自体はネットワークを全く使用しないことを覚えておいてください。

ファイルサイズだけでは不十分なチェックが必要な場合は、テスト用ドキュメントを自分の管理するホストに向けさせ、プレビュー実行中にアクセスログを確認します。サイズはバイトが届いたかどうかを示し、アクセスログはリクエストが行われたかどうかを示します。ホワイトリストに登録したホストが到達不能な場合とブロックされた場合の出力はサイズだけでは区別できませんが、ログで判別できます。

自分のドキュメントでサンプルを実行すれば、1 分程度で 3 つのファイルサイズから、送信者が送ったファイルに対してサービスが何を取得していたかが正確に分かります。

追加リソース