MIMEスニッフィングとX-Content-Type-Optionsの防御
アップロード画像がスクリプトとして実行される事故を、ヘッダ一行で塞げる。ブラウザがContent-Typeを推測する条件と危険性、nosniffが止める判定までを原理から押さえます。
- 1.ブラウザは宣言されたContent-Typeが欠落・曖昧・先頭バイトと矛盾するとき、本文の先頭数百バイトを覗いて型を推測する。この推測(MIMEスニッフィング)が、攻撃者制御のデータをHTMLやスクリプトへ昇格させる経路になる。
- 2.X-Content-Type-Options: nosniff は推測を止め、宣言された Content-Type だけを正とする。スクリプト/スタイルでは MIME ミスマッチを即ブロックし、文書のスニッフィングも禁止して、画像偽装XSSやライブラリの誤実行を防ぐ。
- 3.nosniff は Content-Type を正しく送ることが前提。type が空や text/plain のままでは効果が薄く、ダウンロード強制は Content-Disposition: attachment と組み合わせる。多層防御の一層であり CSP の代替ではない。
ブラウザは、サーバーが Content-Type で宣言した型を必ずしも鵜呑みにしません。宣言が欠けていたり曖昧だったりすると、本文の中身を覗いて「これは本当は HTML ではないか」と推測します。利便性のための機能ですが、これが攻撃者の用意したデータを実行可能なコンテンツへ昇格させる経路になります。ここでは、ブラウザが型を推測する条件と、X-Content-Type-Options: nosniff がどの判定を止めるのかを原理から整理します。
なぜブラウザは型を推測するのか
HTTP レスポンスの本文をどう扱うかは、本来 Content-Type ヘッダ(MIME タイプ)が決めます。text/html ならパースして DOM を構築し、image/png なら画像としてデコードし、text/plain ならテキストとして表示する、という対応です。問題は、この宣言が当てにならないサーバーが歴史的に多かったことです。設定ミスで Content-Type が付かない、すべて text/plain で返す、拡張子と中身が食い違う——こうしたサイトでも「とりあえず正しく表示される」ことをブラウザは競争上求められました。
そこでブラウザは、宣言が信用できないと判断したとき本文の**先頭バイト列(おおむね最初の512バイト程度)**を検査し、既知のシグネチャ(マジックバイト)と照合して型を推し量ります。これが MIME スニッフィング です。挙動は WHATWG の MIME Sniffing Standard で標準化されており、検査するバイトパターンと優先順位が定義されています。
| 先頭バイトの例 | 推測される型 | 意味 |
|---|---|---|
| 3C 21 44 4F 43 ... (<!DOCTYPE) | text/html | HTML文書として解釈・実行され得る |
| FF D8 FF | image/jpeg | JPEG画像 |
| 89 50 4E 47 (\x89PNG) | image/png | PNG画像 |
| 3C 3F 78 6D 6C (<?xml) | text/xml | XML文書 |
| 25 50 44 46 (%PDF) | application/pdf |
スニッフィングが起きる典型条件は、(1) Content-Type ヘッダが無い、(2) application/octet-stream のような汎用型や text/plain で「不明」に近い、(3) 宣言は画像系なのに本文先頭が HTML シグネチャと一致する、といった宣言と中身の矛盾です。ブラウザは「ユーザーの意図に近い方」を選ぼうとして、中身を優先してしまうことがあります。
スニッフィングが攻撃になる構造
推測のどこが危険か。鍵は「型が変わると、どの実行エンジンへ本文が渡るかが変わる」点です。image/png として扱えば画像デコーダへ、text/html として扱えば HTML パーサと JavaScript エンジンへ本文が流れます。攻撃者がコンテンツの先頭バイトを制御できれば、宣言を画像にしておきながら中身を HTML にして、HTML としての解釈を引き出せます。
ユーザー投稿の「画像」を受け付けるサイトを考えます。攻撃者は、ファイル先頭に <script>...</script> を含む HTML を仕込み、拡張子だけ .png にしてアップロードします。サーバーが本文を画像として保存し、配信時に Content-Type: image/png を付けても、本文先頭が HTML シグネチャに見えれば、スニッフィング有効なブラウザは HTML として解釈し、埋め込まれたスクリプトを実行します。投稿が被害サイトと同一オリジンで配信されていれば、これはそのオリジンの権限で動く本物の XSS です。
この攻撃が成立するのは、宣言型より推測型が優先される瞬間です。同種の問題は、text/plain で返したつもりの JSON API レスポンスが HTML と誤認される、application/octet-stream のダウンロード用ファイルがインライン表示で HTML 実行される、といった形でも起こります。被害は注入が成立したオリジンの信頼境界の中で発生するため、同一オリジンポリシーとサイト分離の信頼境界 でいう「同一オリジン=同一の信頼単位」がそのまま被害範囲になります。
nosniff が止める判定
これを断つのが X-Content-Type-Options: nosniff レスポンスヘッダです。値は nosniff の一語のみで、意味は「宣言された Content-Type を最終決定とし、本文からの型推測を行うな」です。nosniff が付いた応答に対し、ブラウザは2種類の強制をかけます。
| 対象 | nosniff 無し | nosniff 有り |
|---|---|---|
| script要素の読み込み | MIMEが曖昧でも実行を試みる | Content-Type が JS系MIMEでなければ実行を拒否 |
| stylesheet の読み込み | text/plain等でも適用し得る | text/css でなければ適用を拒否 |
| 文書/画像などの表示 | 先頭バイトで型を推測し得る | 宣言された Content-Type のまま扱う |
第一に、スクリプトとスタイルシートでは MIME タイプのミスマッチを致命的エラーにします。<script src> で読み込むリソースの Content-Type が JavaScript 系(text/javascript など)でなければ、nosniff 下のブラウザは実行を拒否しブロックします。<link rel=stylesheet> も text/css 以外なら適用しません。これにより、画像 URL やテキストエンドポイントを <script> で参照して中身を実行させる手口が塞がれます。
第二に、文書・画像などその他のリソースでは、宣言型からの昇格スニッフィングを禁止します。Content-Type: image/png と宣言されたものを、本文が HTML に見えるからといって text/html へ格上げすることをしません。先述の画像偽装 XSS が成立しなくなるのはこの効果です。
nosniff が止めるのは「宣言型を無視して中身から別の型へ推測する」昇格です。逆に、最初から Content-Type: text/html と正しく宣言された本文は、nosniff があっても HTML として実行されます。nosniff は推測を禁じるだけで、宣言そのものを安全側に書き換えはしません。だから「アップロード画像が HTML 実行されない」ためには、nosniff に加えてサーバーが画像を必ず image/* で宣言する(少なくとも text/html で配信しない)ことが前提になります。
ダウンロードとスクリプト実行判定への波及
nosniff は単独でなく、関連するヘッダと噛み合って効きます。とくに重要なのがダウンロード時の挙動です。
ユーザー由来ファイルを「表示せずダウンロードさせる」意図なら、Content-Disposition: attachment を付けて保存ダイアログへ誘導します。ただしこれだけでは、ブラウザによってはインラインプレビューや型推測の余地が残ります。nosniff を併用すると、宣言型を尊重しつつ余計な推測を止められるため、両者の組み合わせが定石です。さらに、ユーザー投稿コンテンツは可能なら**本体とは別オリジン(サンドボックス用ドメイン)**で配信し、万一実行されても本体オリジンの信頼境界へ届かないようにします。
| 目的 | 推奨ヘッダの組み合わせ |
|---|---|
| ユーザー投稿画像の表示 | Content-Type: image/png(正確に) + X-Content-Type-Options: nosniff |
| 任意ファイルのダウンロード | Content-Disposition: attachment; filename=... + nosniff |
| JSON API 応答 | Content-Type: application/json + nosniff |
| 静的JS/CSS配信 | 正しい text/javascript・text/css + nosniff |
スクリプト実行判定への影響も実務上見落とされがちです。nosniff 環境では、Content-Type の付け間違いが「動かない」という形で顕在化します。たとえば JS ファイルを誤って text/plain や application/octet-stream で配信していると、nosniff 有効サイトではスクリプトが実行拒否されて壊れます。これは欠陥ではなく、推測に頼らず宣言を正とする設計の当然の帰結です。CDN やストレージから JS/CSS を配信する場合は、配信側の MIME 設定が正しいことを nosniff 導入の前提として確認します。
Content-Type は文字コード(charset)も運びます。nosniff で本文推測を止めても、charset 未宣言だとブラウザがバイト列から文字コードを推測する余地が残り、UTF-7 系の古典的な注入など別系統の問題につながり得ます。Content-Type: text/html; charset=utf-8 のように型と charset を両方明示するのが安全側です。型・charset・符号化(Content-Encoding)の各宣言を矛盾なく揃えることが、推測の入り込む隙をなくす基本になります。
多層防御での位置づけ
nosniff は導入コストが極めて低く(ヘッダ一行)、副作用も「正しい Content-Type を要求する」だけなので、ほぼ全レスポンスへ付けてよい類のヘッダです。多くのセキュリティガイドラインが既定での付与を推奨しています。
ただし nosniff が塞ぐのはあくまで「型推測を悪用する経路」に限られます。Content-Type: text/html で正しく宣言された本文に攻撃者の HTML が混ざっていれば、nosniff があっても XSS は成立します。これは出力エスケープと安全な DOM API で防ぐ領域で、DOM-based XSSのsink/source分類と緩和原理 と Trusted TypesによるDOM-based XSSの構造的防止 の守備範囲です。
理想的な構成は3層です。まず出力エスケープで注入そのものを出さない。次に Content Security Policyの内部動作と回避耐性 で、注入が出ても実行を縛る。そして nosniff で、型推測を悪用して非実行コンテンツを実行コンテンツへ昇格させる経路を断つ。nosniff は安価な土台であって、CSP や Trusted Types の代替ではありません。どれか一つに依存せず重ねることで、注入・実行・昇格のそれぞれの面を独立に塞げます。
Web/フロントエンド Article
MIMEスニッフィングとX-Content-Type-Optionsの防御を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
セキュリティ
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5
導入後に効く点
X-Content-Type-Options: nosniff は推測を止め、宣言された Content-Type だけを正とする。スクリプト/スタイルでは MIME ミスマッチを即ブロックし、文書のスニッフィングも禁止して、画像偽装XSSやライブラリの誤実行を防ぐ。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 5
判断チェックリスト
- 自社の用途が「セキュリティ / Web」に近いか確認する。
- 強みである「ブラウザは宣言されたContent-Typeが欠落・曖昧・先頭バイトと矛盾するとき、本文の先頭数百バイトを覗いて型を推測する。この推測(MIMEスニッフィング)が、攻撃者制御のデータをHTMLやスクリプトへ昇格させる経路になる。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。