画像のURLを直接開くと、問題なく表示されます。サーバー側で同じアドレスにリクエストを送ってもHTTP 200が返り、応答本体は実際のJPEGデータです。しかし、Webページの画像要素では読み込みエラーが発生します。

この現象を説明するには、リクエストの成功を複数の段階に分解する必要があります。バイトを受信したかどうか、画像として解釈可能かどうか、現在ドキュメントがその応答を利用できるかどうかは、それぞれ別の条件です。画像中継経路を追加すると、これらの条件についてサーバーがどこまで責任を持つのかも新たに決定する必要があります。

本記事では、CORP、DNSリバインディング、非同期ストリーミング、画像デコーディングの観点からこの境界線を検討します。コード例はPython 3.11以上とHTTPX 0.28.1を基準としています。接続レイヤーはhttpcore 1.0.9、画像処理の説明はPillow 13.0.0公式ドキュメントを参照しました。ドキュメント確認日は2026年11月4日です。

1. HTTP 200はブラウザの利用許可を含んでいない

応答が次のようだと仮定します。

http
HTTP/1.1 200 OK
Content-Type: image/jpeg
Cross-Origin-Resource-Policy: same-site

この応答を他のサイトのドキュメントから画像として読み込むと、ブラウザが利用をブロックすることがあります。CORPは、リソース提供元が自社の応答をどの起源(オリジン)から利用できるかを制限するポリシーです。Fetch規格はno-corsリクエストの応答に対して個別のCORPチェックを定義しています。no-corsというリクエストモードが、応答のすべてのセキュリティチェックを省略することを意味するわけではありません。Fetch規格のCORP定義

診断時には、次の3層を分離することを推奨します。

確認する層成功の意味まだ起こりうる失敗
HTTP交換ステータスコードと応答本体を受信した内容がエラーページである、または権限と合致しない
形式解釈画像デコーダーがフォーマットを識別した破損したフレーム、過剰なピクセル数、ブラウザごとのサポート差異
ドキュメント内利用当該ドキュメントのセキュリティコンテキストで応答を利用できるCORP、ドキュメントのリソースポリシーなど

CORSとCORPも区別する必要があります。CORSはクロスオリジン応答を共有するプロトコルであり、CORPは特定のクロスオリジン・クロッサイトリソースの利用を制限します。一般的なクロスオリジン画像表示に常にCORS許可が必要とは限りませんが、CORPはその利用を妨げることがあります。画像にcrossoriginを付けてCORSリクエストに変換しても、元サーバーが必要とする許可応答を提供する必要があります。Fetch規格のCORSプロトコル

サーバー側でダウンロードが成功したという観察は、ネットワークとファイル形式へと原因を絞り込む根拠です。これをブラウザのポリシーまで通過した証拠として拡大解釈すると、原因を見逃します。

2. 中継サーバーは新しい応答の発行者となる

許可された外部リソースをサーバー経由で取得し、プレビューとして提供する構造を選択すると、リクエスト経路は次のように変わります。

text
브라우저
  ├─ 원본 이미지 직접 요청
  └─ 실패 시 인증된 미리보기 요청
        → 목적지 정책 검사
        → 검증된 주소로 연결
        → 제한된 크기의 본문 수신
        → 허용한 이미지 형식 검증
        → 새 응답 생성

中継サーバーが返すのは、自社の起源から発行した新しいHTTP応答です。したがって、応答ヘッダーを明示的に構成する必要があります。元のSet-Cookieをそのまま転送すると、外部サーバーの状態設定を中継サービスの応答に混入させることになります。元のContent-Typeも検証結果の代わりにはなりません。返すMIMEタイプとキャッシュポリシーは、中継経路の契約として定めるべきです。

リクエスト方向も同様です。ユーザーのセッションCookieとサービス用認証ヘッダーは、元画像サーバーに送られません。プレビューリクエストを認証する行為と、外部宛先に資格情報传递する行為は互いに独立です。

この構造には権限の移動があります。ブラウザが実行していた外部リクエストをサーバーが代行することで、サーバーのネットワーク到達範囲が利用されます。認証されたユーザーであっても、内部サービスやメタデータアドレスを画像URLとしてリクエストする権限まで取得するわけではありません。

3. DNSチェックは実際の接続に結合されていなければならない

URLのホストを一度解決し、プライベートアドレスが含まれないことを確認する実装は自然です。しかし、チェック後にHTTPクライアントが同じホストを再解決すると、時間差が発生します。

text
t0: 검증 단계의 DNS 응답 → 허용한 공인 주소
t1: 연결 단계의 DNS 응답 → 허용하지 않은 내부 주소

検証結果が実際のソケットの宛先を拘束できないTOCTOU問題です。宛先を検証した後、そのアドレスへ接続するように紐付ける必要があります。OWASPも、DNS再解決とリダイレクトをSSRF防御において検討すべき対象として取り上げています。SSRF防御ガイド

ここで必要な不変条件は単純です。

新しい接続を作成する際に選択するIPは、その接続に適用するポリシーで検証したアドレス集合に属していなければならない。

ホストが複数のアドレスを返す場合、IPv4とIPv6を併せて扱う必要があります。一部のアドレスが拒否対象の場合、リクエスト全体を拒否するか、検証済みアドレスのみを候補として残すかも明示的なポリシーです。保守的な実装では混在した応答を一律拒否することがありますが、分割DNSのような環境では正常なリクエストまで失敗する可能性があります。

接続するIPを固定しつつ、元のホストの意味は保持する必要があります。

接続の要素使用すべき値
TCP接続先検証済みの数値IP
HTTP Host元のURLのホストと必要なポート
TLS SNI元のホスト名
証明書の検証対象元のホスト名

URL自体をIPアドレスに置き換える実装は、仮想ホストルーティングとTLS検証を同時に変えてしまう可能性があります。ネットワーク接続先のみを固定し、上位プロトコルのホスト識別は維持するレイヤー分離が必要です。

リダイレクトも同じ原則に従います。自動追跡をオフにすれば、宛先ポリシーを単純に保ちやすくなります。追跡を許可する場合は、各リダイレクト先のURLポリシーと新しい接続のIPポリシーを再適用し、リダイレクト回数と全体時間にも制限を設ける必要があります。最初のアドレスの検証結果は、次のアドレスに引き継がれません。

サービス間通信に必要な内部アドレス例外を、外部プレビュークライアントにそのまま再利用しないことも重要です。ポリシーは機能ごとに付与すべきです。同じネットワークユーティリティを使用していても、公開リソースの取得と内部サービス呼び出しの許可範囲は分離できなければなりません。

4. HTTPXのTransportとhttpcoreのNetwork Backendを区別する

HTTPXはリクエスト・応答境界のTransport APIを提供します。httpcoreはコネクションプールと実際のネットワーク動作を扱っており、カスタムNetwork Backendを接続できます。宛先IP固定は、実際の接続が作成される地点まで下りて初めて効力を発揮します。HTTPX Transport API、httpcore Network Backend

実際の実装では、公開拡張ポイントとライブラリ内部構造を区別する必要があります。例えば、HTTPX 0.28.1のTransport内部で_pool._network_backendを置き換える方式は、内部属性に依存します。小さなアダプターで依存範囲を集約し、バージョンを固定し、アップグレード時に実際の接続経路が変わっていないかを検証する必要があります。公開Network Backendインターフェースが存在するという事実は、上位ライブラリのすべての組み立て方法まで安定した契約になるわけではありません。

環境プロキシも接続モデルに含まれます。HTTP_PROXYやHTTPS_PROXYを自動的に使用すると、直接検証したサーバーではなくプロキシへ接続する可能性があります。直接接続を前提に設計した場合は、trust_env=Falseなどでその前提を強制する必要があります。プロキシが必要な環境では、プロキシが実行するDNS解決と宛先制御まで別途設計する必要があります。HTTPX環境変数ドキュメント

コネクションプールを共有すると、接続再利用とTLSコストにおいて利点が生まれます。同時に、ユーザーごとの認証状態、宛先ポリシー、ポリシー変更時の既存接続の処理も考慮する必要があります。リクエストごとのクライアントは分離が単純ですが、接続再利用の利点を減らします。性能のためにプールを共有する場合は、どのセキュリティコンテキスト同士を共有するのかを最初に決める必要があります。

5. 応答サイズ、総時間、デコーディングコストは異なる予算である

Content-Lengthは事前判断に使用できますが、累計受信量のチェックの代わりにはなりません。ヘッダーが存在しない場合や、HTTPコンテンツ圧縮により、実際に処理する本体サイズと異なる可能性があるためです。

HTTPXのストリーミングAPIでは、チャンクを読み込みながらサイズを制限できます。以下のコードは、宛先ポリシーが適用されたクライアントをすでに受け取っているという前提で、本体サイズと待ち時間を制限する部分のみを示しています。URL検証やDNS固定を実装した完全なプロキシコードではありません。

python
import asyncio
import httpx


class PreviewTooLarge(Exception):
    pass


async def read_bounded(
    client: httpx.AsyncClient,
    url: str,
    *,
    max_bytes: int = 10 * 1024 * 1024,
    budget: float = 15.0,
) -> bytes:
    async with asyncio.timeout(budget):
        async with client.stream("GET", url) as response:
            response.raise_for_status()
            body = bytearray()

            async for chunk in response.aiter_bytes(
                chunk_size=64 * 1024
            ):
                if len(body) + len(chunk) > max_bytes:
                    raise PreviewTooLarge()
                body.extend(chunk)

            return bytes(body)

10 MiBと15秒は設計例です。リソースサイズの分布とサーバーが許容できる同時リクエスト数に基づいて調整してください。

HTTPXのread timeoutは、次のチャンクを待っている時間に対して適用されます。データが少しずつ継続的に到着する場合、リクエスト全体の時間は長引く可能性があります。asyncio.timeout()でリクエストと本体受信を囲めば、別の全体待ち時間予算を設定できます。ただし、キャンセルに協力しない同期処理を、所定の時刻に強制終了する機能ではありません。HTTPX timeoutドキュメント、Python asyncio timeoutドキュメント

サイズ制限の適用タイミングも見る必要があります。aiter_bytes()はHTTPコンテンツデコーディングが適用されたバイトを提供します。上記コードはアプリケーションが保持する本体のサイズを制限しますが、デコーダー内部の一時的なメモリ使用量まで同じ値で制限するわけではありません。小さなchunk_sizeも、プロセス全体のメモリ上限ではありません。HTTPXストリーミング応答ドキュメント

厳格なリソース制限が必要であれば、転送層のバイト、HTTP解凍後のバイト、画像のデコード済みピクセルをそれぞれ区別する必要があります。1リクエストの制限と併せて同時処理量も制限しなければ、全体的なメモリ予算は成立しません。

text
메모리 수요 ≈ 동시 요청 수 ×
    (본문 버퍼 + 디코딩된 픽셀 + 코덱 작업 공간)

この式はおおよその予算モデルです。バッファコピー、接続状態、コーデックごとの動作が加わるため、正確な上限を意味するものではありません。

6. Pillowの識別、検証、デコーディングは異なる段階である

画像処理においても、関数一つの成功範囲を正確に読み取る必要があります。

PillowのImage.open()は遅延処理を使用しています。ファイル形式を識別したからといって、全ピクセルをデコードしたわけではありません。verify()も、画像データを完全にデコードせずファイルの問題を検査するAPIです。これをすべてのフォーマットとフレームに対する完全なデコード保証と解釈すると、検証範囲を過大評価することになります。Pillow Imageドキュメント、Image.verifyドキュメント

したがって、プレビュー経路の契約を先に決めることを推奨します。許可したラスタフォーマットのみを受け取るか、静的な1フレームを返すか、アニメーション全体を保持するかによって、コストと検証範囲が変わります。

静的プレビューであれば、幅・高さと総ピクセル数を制限した後に必要なフレームをデコードし、定められた形式で再エンコードする方法を選択できます。アニメーションを維持する場合は、フレーム数と累計処理量も予算に含まれます。再エンコードは処理コストと表現変更に伴うため、すべての画像に対して無条件に適用するデフォルト値と見ることは困難です。

デコーダーを別スレッドで実行すれば、イベントループの遅延を減らすことができます。しかし、コルーチンの待ち状態がキャンセルされても、既に実行中のスレッドタスクが即時終了するとは想定してはいけません。強制終了可能な実行時間分離が必要であれば、別プロセスとOSのリソース制限まで検討する必要があります。

7. 復旧経路には終了条件とリクエストの世代が必要である

画像のonerrorは、原因の分類に十分な情報を与えません。ポリシーブロック、期限切れアドレス、形式エラー、ネットワーク失敗が同じイベントとして届く可能性があります。

したがって、直接読み込みが失敗した場合はサーバー中継を限定して試みる状態遷移を設けるものの、失敗を特定の要因に断定しない方が望ましいです。

text
직접 요청 ─성공→ 표시
    │ 실패
    ▼
중계 요청 ─성공→ 표시
    │ 실패
    ▼
오류 안내와 사용자가 선택하는 재시도

セッション復旧を追加する場合は、サービスが所有するAPIアドレスのみを対象とし、自動試行回数を別途制限する必要があります。すべての外部画像失敗が認証更新を開始することになると、無関係な障害がセッション処理まで波及します。

もう一つの境界線は、リクエストの時間的寿命です。画像Aが失敗して復旧を待っている間に、画面が画像Bに切り替わる可能性があります。遅れて完了したAの処理がBを再度リクエストしたり、Bのエラー状態を消去したりすると、状態が混ざります。ソース別コンポーネントの寿命、世代番号、キャンセルシグナルなどを使用し、古い結果の適用を阻止する必要があります。

署名付き元URLを復旧目的で無制限に変更することも避けるべきです。リトライ識別子は自社中継リクエストに付与し、元URLは別パラメータ値として传递できます。元文字列の保持と宛先検証は、同時に充足すべき条件です。

8. テストは実際のポリシーが執行されるレイヤーまで下りていく必要がある

宛先検証関数全体をモックしたテストでは、検証結果とソケット接続先が一致するかを確認できません。HTTPXのMockTransportでHTTP応答を代わりに返すテストも、下位ネットワーク接続を実行しません。

接続境界を検証するには、下位ネットワークバックエンドで実際にリクエストされたアドレスを記録するか、httpcoreのモックストリームを使用して応答後の動作まで進めることができます。公開アドレスからリダイレクト応答を受信した後、次の内部アドレスへの接続が実際のソケットを作成する前に拒否されるかを確認するような方法です。

検証シナリオ観察すべき結果
DNSチェックと接続の間のアドレス変更チェックと無関係な再解決経路が接続を変更できない
公的・非公的アドレスが混在した応答指定したアドレス集合ポリシー通りに処理される
公的アドレスから内部アドレスへのリダイレクト内部ソケット接続前に拒否される
サイズ制限を超えるチャンクバッファに追加する前に拒否される
遅いが途切れなかった応答全体待ち時間予算に従って終了する
MIMEタイプを偽装したHTMLまたは破損した画像画像応答として発行されない
画像ソース変更後に到着した以前の復旧結果現在の画像状態に適用されない

ブラウザ検証も必要です。画像要素のcompleteは読み込み失敗後もtrueである可能性があるため、それだけで成功と判定してはいけません。ラスタ画像であればnaturalWidth > 0のような条件と実際の画面を併せて確認し、拡大表示など別リクエストを作成する経路も確認する必要があります。

最後に、転送とレンダリングが正常でも、画像の内容がトピックと一致するかは残ります。ネットワーク宛先の信頼性、ファイル形式の適合性、コンテンツの意味的適合性は、それぞれ異なる検証対象です。

外部画像中継の設計基準は、いずれかの関数の成功の有無ではありません。検証した宛先に接続されたか、定められた予算内で処理されたか、許可された表現のみを新しい応答として発行したか、古い復旧結果が現在の状態を変更していないかを、境界ごとに説明できる必要があります。