X-Content-Type-Options: nosniff 解説|MIMEスニッフィング対策
- X-Content-Type-Options: nosniff は、ブラウザに「Content-Type を推測するな」と命じる一行の防御だ。
- Internet Explorer 系で問題化した MIME スニッフィングは、モダンブラウザでも完全には消えていない。
- 正しい Content-Type を返すことが前提のうえで、HTML・JSON・静的アセットの全レスポンスに nosniff を付ける。
- CSP や HSTS と並べて セキュリティヘッダー一覧 の定番セットに入れるのが最短ルートだ。
X-Content-Type-Options: nosniff とは
HTTP レスポンスには Content-Type ヘッダーがあり、ブラウザに「このバイト列は HTML か、JavaScript か、画像か」を伝える。X-Content-Type-Options は、その宣言を推測で上書きさせないためのレスポンスヘッダーだ。
現在定義されている値は nosniff のみ。設定例は次のとおり。
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
X-Content-Type-Options: nosniff
仕様上は非標準ヘッダー(X- プレフィックス)だが、Chrome・Firefox・Safari・Edge のすべてが実装しており、Mozilla Observatory や OWASP のチェックリストでも必須項目として扱われる。CDN・リバースプロキシ・アプリのどの層で付けても効果は同じだが、オリジンから一貫して返すのが監査上わかりやすい。
nosniff は「魔法の XSS 対策」ではない。サーバーが間違った Content-Type を返している限り、nosniff はその間違いを正してくれない。あくまで「宣言どおりに扱え、script や style として実行するな」という最後の安全弁として位置づける。
仕様と歴史
X-Content-Type-Options は Microsoft が Internet Explorer 8 向けに導入したヘッダーで、後に Fetch Living Standard に取り込まれた。値は nosniff 以外は定義されておらず、未知の値は無視される。
| 項目 | 内容 |
|---|---|
| ヘッダー名 | X-Content-Type-Options |
| 有効な値 | nosniff のみ |
| 適用対象 | HTTP レスポンス(リクエストには効かない) |
| 主な効果 | MIME スニッフィングの抑制、script/style の厳格な MIME チェック |
| 標準化 | WHATWG Fetch Standard に記載(非 IETF RFC) |
「X- プレフィックスは非推奨」という一般的な HTTP ヘッダーの命名規則にもかかわらず、実装・ツール・監査のすべてがこの名前を前提にしている。改名の議論はあるが、実務では X-Content-Type-Options をそのまま使うのが正解だ。
MIME スニッフィング(コンテンツタイプスニッフィング)の仕組み
MIME スニッフィングとは、ブラウザが Content-Type ヘッダーとファイル実体が食い違うとき、先頭バイト列や拡張子から「本当の種類」を推測する挙動のことだ。正式には content type sniffing と呼ばれ、Fetch 仕様の「MIME type sniffing」アルゴリズムに整理されている。
ブラウザが推測に使う手がかり
推測の典型パターンは次のとおり。
- マジックバイト(ファイルシグネチャ) — PNG は
89 50 4E 47、PDF は%PDF、ZIP はPKなど、先頭数バイトで種類を判定 - HTML トークン —
<html、<script、<!DOCTYPE、<svgなどが見えれば HTML または XML 扱い - 拡張子ヒント — URL パスや
Content-Dispositionの filename から.htmlを拾う - polyglot ファイル — JPEG としても HTML としても解釈できるバイト列を意図的に作る
- BOM(Byte Order Mark) — UTF-8 BOM
EF BB BFの有無でテキスト判定に影響
モダンブラウザは Fetch 仕様に沿ってスニッフィングを大幅に抑制している。とはいえ 「ヘッダーと実体の不一致」 は、セキュリティ監査・ペネトレーション・バグ報奨プログラムで今も指摘される。nosniff は HTTP レイヤーで「推測禁止」を明示的に宣言できる rare な手段だ。
スニッフィングが許可される条件(概要)
Fetch 仕様では、おおよそ次のような場合にスニッフィングが行われる(nosniff がない場合)。
| 条件 | スニッフィングの可能性 |
|---|---|
Content-Type が欠落 | 高い — default_type や拡張子から推測 |
Content-Type: application/octet-stream | 高い — 汎用バイナリは推測対象 |
Content-Type: text/plain | 中〜高 — HTML トークン検出で HTML 化 |
Content-Type: unknown/xxx | 高い — 未知の MIME は推測 |
Content-Type: image/jpeg だが中身が HTML | 低〜中 — ブラウザ・文脈依存 |
X-Content-Type-Options: nosniff あり | 原則禁止 — 宣言 MIME を尊重 |
Chrome 等では nosniff 付きレスポンスにおいて、JavaScript として実行可能な MIME タイプ以外(text/plain や application/octet-stream など)の script はブロックされる。CSS も同様に text/css 以外は stylesheet として適用されない。Console には Refused to execute script ... because its MIME type ('text/plain') is not executable のようなメッセージが出る。
なぜブラウザはスニッフィングするのか
歴史的には、Web サーバーが MIME を正しく返さないことが日常茶飯事だった。.js が text/plain で返る、.css が application/octet-stream になる——こうした「壊れたサイト」を動かすため、ブラウザは寛容な推測ロジックを持っていた。
セキュリティと互換性のトレードオフは今も続いている。nosniff を全サイトに強制すると、MIME 未設定のレガシー資産が一斉に壊れる。だからこそサイト運営者側が正しい Content-Type を返し、nosniff で宣言を守らせるという分担が現代のベストプラクティスになっている。
MIME スニッフィング攻撃の全体像
MIME スニッフィング攻撃とは、サーバーが返した Content-Type と異なる種類としてブラウザに解釈させ、意図しないコード実行やデータ窃取を起こす攻撃クラスの総称だ。XSS の一種として分類されることも多いが、注入ポイントは「HTML テンプレート」ではなく「ファイルの配信 MIME」にある点が特徴的だ。
攻撃が成立する前提条件
次の条件が揃うと、MIME スニッフィング経由の XSS が成立しやすい。
- 攻撃者がファイル内容をコントロールできる(アップロード、プロフィール欄、コメント添付など)
- サーバーが誤った Content-Type で再配信する(拡張子だけ見る、マジックバイト未検証)
- 同一オリジン上でその URL を読み込める(
<script src>、<iframe>、<object>、直リンク) - nosniff がない、またはブラウザがスニッフィングする(古い IE、設定ミス)
Cookie の SameSite や CSP があっても、同一オリジン内の script 実行は制限が緩い。MIME スニッフィングで「同一オリジン上の text/plain ファイルを script として実行」できれば、document.cookie や fetch('/api/me') が通る。
攻撃フロー(抽象)
[攻撃者] 悪意あるコンテンツをアップロード or 保存
↓
[サーバー] 拡張子・推測のみで Content-Type を決定(例: image/jpeg)
↓
[被害者] 同一オリジン URL をブラウザが読み込む
↓
[ブラウザ] スニッフィングで HTML/JavaScript として解釈
↓
[結果] セッション窃取・DOM 操作・フィッシング UI 注入
Internet Explorer 時代の攻撃シナリオ(レガシー)
MIME スニッフィングが「教科書的な脆弱性」として語られる背景には、Internet Explorer 6〜11 の aggressive なスニッフィングがある。IE は次のような条件で、宣言された Content-Type を無視して HTML として解釈した。
- レスポンスが
Content-Type: text/plainまたはContent-Type: application/octet-stream - ファイル先頭付近に HTML らしいマーカー(
<script>、<htmlなど)が存在 - 同一オリジン上で
<iframe src="/uploads/evil.txt">や<object data="...">経由で読み込まれる
典型攻撃:アップロード + iframe 埋め込み
2000年代の掲示板・メール添付・プロフィール画像アップロードで、次のチェーンが何度も再現された。
[攻撃者] evil.jpg として HTML+script をアップロード
↓
[サーバー] Content-Type: image/jpeg で保存・配信(拡張子だけ見ている)
↓
[IE] 先頭に HTML トークンを検出 → HTML として解釈
↓
[被害者] 同一オリジン iframe 内で script 実行 → Cookie 窃取
攻撃者が仕込むファイルは polyglot であることが多い。JPEG のマジックバイト FF D8 FF で始まりつつ、コメント領域や末尾に <script>...</script> を埋め込む。サーバー側の MIME 判定は「JPEG っぽい」と image/jpeg を返すが、IE は HTML として実行する。
polyglot ファイルの作り方(防御者向け理解)
攻撃者視点の知識だが、防御設計には必須だ。
| 手法 | 概要 | サーバー側の見え方 |
|---|---|---|
| JPEG + HTML | FF D8 FF 後に JPEG コメントセクションへ HTML を埋め込む | image/jpeg と判定されやすい |
| GIF + HTML | GIF89a ヘッダー + 最小フレーム + 末尾に script | image/gif |
| PDF + JavaScript | PDF 内の JS オブジェクト(別系統の脅威) | application/pdf |
| ZIP 偽装 | 先頭を PK で始め拡張子を .zip に | アーカイブ扱い |
マジックバイトと拡張子の一致だけでは不十分だ。ファイル全体をスキャンし、HTML トークンや script パターンを含むアップロードは拒否するのが安全な設計だ。
IE 固有の「ダウンロード後実行」問題
IE には 「ダウンロードしたファイルをブラウザ内で開く」 挙動も絡んだ。Content-Disposition: attachment がなく、text/plain で HTML 断片を返すと、ユーザーが「テキストファイル」だと思って開いた瞬間に script が走る事例も報告されている。nosniff はこの経路でも「plain として扱え、HTML として解釈するな」と指示する。
Internet Explorer のサポート終了後も、nosniff はモダンブラウザの script/style ブロック、JSON スニッフィング抑制、セキュリティスキャナーの要件として価値を持つ。レガシー対策のつもりで入れておくと、結果的に今の Chrome でも効く典型例だ。
現代でも起きうる攻撃シナリオ
IE を引きずらなくても、ファイルアップロード + 再配信の設計ミスは今も XSS の温床になる。バグバウンティでは「Uploaded HTML served as text/plain → XSS」系の報告が定期的に受理される。
シナリオ A:text/plain 配信のユーザー生成コンテンツ
社内 Wiki やサポートチケットで、ユーザーが .txt をアップロードできるとする。サーバーは /files/note.txt を Content-Type: text/plain で返す。攻撃者は中身を次のようにする。
<script>
fetch('/api/me', { credentials: 'include' })
.then(r => r.json())
.then(data => { /* セッション情報を外部へ */ });
</script>
nosniff がなく、かつブラウザや埋め込み WebView の実装次第では HTML として解釈されうる。同一オリジンなので Cookie 付き fetch が通り、セッション窃取に直結する。
nosniff ありでは、<script src="/files/note.txt"> は MIME エラーでブロックされる。ただし HTML として直接ナビゲート(アドレスバーで開く)や <iframe src> の挙動は文脈依存なので、危険なコンテンツはそもそも保存しないことが根本対策だ。
シナリオ B:CDN の default_type 任せ
nginx や S3 互換ストレージで MIME マップが未設定だと、.js が application/octet-stream で返ることがある。CSP や ES module の import は厳密な MIME を要求するため、機能障害とセキュリティの両方で問題になる。nosniff 付きでは octet-stream の script は実行されない——正しい MIME を直す必要がある。
# 確認例
curl -sI https://example.com/assets/app.mjs | grep -i content-type
# Content-Type: application/octet-stream ← 要修正
シナリオ C:JSONP 時代の残骸
application/javascript や text/javascript で JSON 風データを返し、<script src="/api/data"> で読み込むパターン(JSONP)は、XSS フィルタを迂回する手段として悪用された歴史がある。nosniff と CSP の script-src 制限は、このクラスの「MIME すり替え」を封じる。
シナリオ D:S3 / オブジェクトストレージの Content-Type 未設定
AWS S3 にアップロードした際、Content-Type メタデータを付け忘れると application/octet-stream になる。CloudFront 経由で配信すると、ブラウザがスニッフィングしたり、module スクリプトが拒否されたりする。アップロード時に MIME を明示し、配信時に X-Content-Type-Options: nosniff を CDN で足すのが定石だ。
シナリオ E:開発者ツール・ステージングの設定漏れ
本番 nginx には nosniff があるが、ステージングやプレビュー環境の vite preview・python -m http.server には付いていない——というパターンもある。攻撃者がステージング URL を標的にするケースは少ないが、全環境で一貫したヘッダーが監査の要件になることは多い。
nosniff が変えるブラウザ挙動
X-Content-Type-Options: nosniff を付与すると、主要ブラウザはおおよそ次のように振る舞う。
| リソース種別 | nosniff なし(概要) | nosniff あり |
|---|---|---|
<script src> | 非 script MIME でも実行される場合がある | script として許可された MIME 以外はブロック |
<link rel="stylesheet"> | CSS 以外が stylesheet として効く場合がある | text/css 以外は拒否 |
<img> 等 | image/* 以外から HTML スニッフィングの余地 | image/* を HTML として解釈しない |
| fetch/XHR の JSON | text/plain 等へのスニッフィング | application/json 等の宣言を尊重 |
| Worker / Service Worker | 厳格な MIME 要求(もともと厳しい) | 変更なし(元から厳格) |
ES Module (type="module") | MIME 誤りで実行失敗しやすい | 誤 MIME は確実にブロック |
JavaScript として許可される MIME(Chrome 系)
Fetch 仕様および Chromium の実装では、classic script として実行可能な MIME はホワイトリスト方式だ。代表例:
text/javascriptapplication/javascriptapplication/x-javascripttext/ecmascriptapplication/ecmascript
text/plain や application/json は 実行不可。ES module はさらに text/javascript 系に限定される実装が一般的だ。
重要:nosniff は「配信レスポンス」に付ける。リクエストの Content-Type(POST ボディ)には効かない。ファイルアップロード POST より、GET で再配信するときのヘッダーが焦点になる。
正しい Content-Type 指定一覧
nosniff の前提は、種類ごとに正確な MIME タイプと charset を返すことだ。よく使う値を表にまとめる。
Web ページ・アプリケーション資産
| 種類 | 推奨 Content-Type | charset | 補足 |
|---|---|---|---|
| HTML 文書 | text/html | charset=utf-8 必須推奨 | charset 省略は文字化け・XSS の温床になりうる |
| XHTML | application/xhtml+xml | charset=utf-8 | text/html より厳格な XML パーサ |
| CSS | text/css | charset=utf-8 推奨 | @import や module 連携で MIME 厳格化 |
| JavaScript(classic) | text/javascript | charset=utf-8 推奨 | 歴史的に application/javascript も可 |
| JavaScript(ES module) | text/javascript | 任意 | module スクリプトは MIME チェックが厳しい |
| JSON API | application/json | charset=utf-8 推奨 | text/json は非推奨 |
| JSON-LD | application/ld+json | charset=utf-8 | SEO 構造化データ |
| XML | application/xml | charset=utf-8 | XXE 対策は別途必要 |
| WebAssembly | application/wasm | なし | octet-stream だとインスタンス化失敗 |
| Source Map | application/json | — | .map ファイル |
| Markdown(配信時) | text/plain または text/markdown | utf-8 | HTML 変換するなら変換後に text/html |
画像・メディア
| 種類 | 推奨 Content-Type | 補足 |
|---|---|---|
| PNG 画像 | image/png | マジックバイト 89 50 4E 47 と整合 |
| JPEG 画像 | image/jpeg | EXIF 付きでも MIME は jpeg |
| GIF 画像 | image/gif | アニメ GIF も同じ MIME |
| WebP | image/webp | 古い nginx では types 追加が必要 |
| AVIF | image/avif | 比較的新しい。MIME マップ要確認 |
| SVG | image/svg+xml | XSS 载体になりうる——サニタイズか配信禁止を検討 |
| ICO | image/x-icon または image/vnd.microsoft.icon | ファビコン |
| MP4 動画 | video/mp4 | Range リクエストと整合 |
| WebM 動画 | video/webm | |
| MP3 音声 | audio/mpeg | |
| Ogg | audio/ogg または video/ogg | 中身に応じて使い分け |
ドキュメント・ダウンロード
| 種類 | 推奨 Content-Type | 補足 |
|---|---|---|
application/pdf | インライン表示時は CSP も確認 | |
| ZIP アーカイブ | application/zip | 実行可能内容の再配信は別問題 |
| 汎用ダウンロード | application/octet-stream | Content-Disposition: attachment を併用 |
| CSV | text/csv | charset=utf-8 推奨 |
| プレーンテキスト | text/plain | ユーザー生成なら HTML トークン検査 |
フォント
| 種類 | 推奨 Content-Type | 補足 |
|---|---|---|
| WOFF | font/woff | CORS ヘッダーとセット |
| WOFF2 | font/woff2 | 現行の主流 |
| TTF / OTF | font/ttf / font/otf | |
| EOT(レガシー) | application/vnd.ms-fontobject | IE 専用。新規では不要 |
拡張子からの推測だけに頼ると、.wasm が octet-stream になる、.mjs が text/plain になる、といった事故が起きる。ビルド成果物ごとに MIME を明示し、デプロイ後に DevTools の Network で spot check する。
# nginx: 不足している MIME の例
types {
application/wasm wasm;
text/javascript js mjs;
image/webp webp;
image/avif avif;
font/woff2 woff2;
}
「わからないファイルは全部 octet-stream」は、ダウンロード専用なら正しいが、ブラウザ内で解釈されるパス(/static/、/uploads/)では危険だ。octet-stream + nosniff でも、古いクライアントや <embed> 経由で想定外の挙動が残る可能性がある。ユーザー生成ファイルには attachment を強制する。
multipart/form-data と boundary の落とし穴
ファイルアップロードは Content-Type: multipart/form-data; boundary=----WebKitFormBoundary... 形式で送られる。boundary はパート(フィールドやファイル)を区切る文字列だ。nosniff とは別レイヤーだが、multipart の設計ミスは「種類の誤判定」やパーサ混乱につながり、結果としてセキュリティ事故の入口になる。
boundary 不一致・欠落
| 問題 | 症状 | セキュリティ上のリスク |
|---|---|---|
boundary= パラメータ欠落 | サーバーが 400 を返すか、ボディ全体を1フィールド扱い | 想定外のパースで検証ロジックを bypass |
| 宣言 boundary とボディ不一致 | パート境界がずれ、ファイル内容が別フィールドに混入 | ファイル名偽装・メタデータ改ざん |
| boundary に予約文字 | " や改行を含む boundary でパーサが混乱 | 古いフレームワークでクラッシュや DoS |
| クライアント偽装 Content-Type | multipart 宣言だが実体は JSON | ミドルウェアの二重パースで意図しない処理 |
| 巨大パート・パート数過多 | メモリ枯渇 | DoS |
サーバー側の実装原則:
- boundary はサーバーが生成する必要はない(クライアントが送る)が、パースライブラリに任せ、自前で split しない
- 各パートの
Content-Typeは参考情報——filename="evil.php.jpg"やContent-Type: image/jpegは信頼しない - マジックバイト + 許可リストで実体を判定し、保存時に拡張子を正規化する
- 再配信時は検証済み MIME のみ返し、HTML 系(text/html、image/svg+xml、application/xhtml+xml)は拒否する
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----Boundary123
------Boundary123
Content-Disposition: form-data; name="file"; filename="photo.jpg"
Content-Type: image/jpeg
<バイナリ本体>
------Boundary123--
再配信 GET レスポンス側では、ここで決めた MIME に加え X-Content-Type-Options: nosniff を必ず付ける。アップロード POST レスポンス(302 リダイレクト等)にも付けておくと、スキャナーの指摘を一括で消せる。
image/svg+xml は XML ベースで script を含められる。nosniff があっても、許可リストに SVG を入れないのが安全。どうしても必要ならサニタイズパイプラインを別途構築する。
nginx での設定例
nosniff は全レスポンスに付けるのが定石。エラーページ(404/500)やリダイレクト応答にも届くよう、always 相当の設定にする。
基本設定(server ブロック全体)
server {
listen 443 ssl http2;
server_name example.com;
# 全レスポンスに nosniff(4xx/5xx 含む)
add_header X-Content-Type-Options "nosniff" always;
include mime.types;
default_type application/octet-stream;
location / {
try_files $uri $uri/ /index.html;
}
}
always を付けないと、nginx が生成する 4xx/5xx レスポンスにはヘッダーが載らない。セキュリティスキャンはエラーページも見るため、always は必須に近い。
location 継承の落とし穴
nginx では子 location で add_header を書くと、親の add_header がすべて消える(同一レベルで上書きされるわけではなく、子で宣言したヘッダーだけが残る)。
server {
add_header X-Content-Type-Options "nosniff" always;
location /api/ {
proxy_pass http://backend;
# ここで add_header を書くと、親の nosniff が消える!
add_header X-Content-Type-Options "nosniff" always; # 子でも必ず再宣言
add_header Cache-Control "no-store" always;
}
}
運用ルール: nosniff を付ける location ブロックすべてで always 付きで再宣言するか、more_set_headers(headers-more モジュール)で一元管理する。
静的ファイルと MIME マップ
http {
include mime.types;
default_type application/octet-stream;
types {
text/javascript js mjs;
application/wasm wasm;
image/webp webp;
image/avif avif;
font/woff2 woff2;
}
server {
add_header X-Content-Type-Options "nosniff" always;
location /static/ {
alias /var/www/static/;
expires 30d;
# 親の add_header が効く(子で add_header しない限り)
}
location ~* \.(jpg|jpeg|png|gif|webp|avif)$ {
expires 7d;
add_header X-Content-Type-Options "nosniff" always;
add_header Cache-Control "public, immutable";
}
}
}
リバースプロキシ(API バックエンド)
location /api/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# バックエンドがヘッダーを返さない場合の保険
add_header X-Content-Type-Options "nosniff" always;
}
バックエンドと nginx の両方で nosniff を付けると重複するが、同じ値なら問題にならない。監査上は「どちらか一方で一元管理」とするチームも多い。
ユーザーアップロードの配信
location /uploads/ {
alias /var/www/uploads/;
add_header X-Content-Type-Options "nosniff" always;
add_header Content-Disposition "attachment" always;
# インライン表示を禁止し、ダウンロード専用にする
}
Content-Disposition: attachment は nosniff と別軸の防御だが、ユーザー生成ファイルではセットで検討する価値が高い。
Express(Node.js)での設定例
Express ではミドルウェアで全ルートに付与するのが基本。静的ファイル・API・エラーハンドラすべてをカバーする。
手動ミドルウェア
import express from 'express';
const app = express();
// 最初のミドルウェアとして登録
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
next();
});
app.get('/api/user', (req, res) => {
res.type('json'); // Content-Type: application/json; charset=utf-8
res.json({ id: 1, name: 'example' });
});
app.use(express.static('public', {
setHeaders(res, filePath) {
res.setHeader('X-Content-Type-Options', 'nosniff');
// express.static は拡張子から Content-Type を推測する
// .mjs が text/plain になる場合は mime パッケージで拡張登録
},
}));
app.listen(3000);
helmet を使う場合(推奨)
import express from 'express';
import helmet from 'helmet';
const app = express();
// nosniff のみ
app.use(helmet.noSniff());
// または他のセキュリティヘッダーと一括
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
},
},
// noSniff はデフォルトで有効
}));
app.listen(3000);
helmet v7 以降は helmet() だけで X-Content-Type-Options: nosniff が有効になる。個別に無効化する場合のみ helmet({ xContentTypeOptions: false }) とする。
ファイル送信と MIME 明示
import path from 'node:path';
import express from 'express';
const app = express();
app.use(helmet());
app.get('/download/:id', async (req, res) => {
const file = await getVerifiedFile(req.params.id);
// DB に保存した検証済み MIME を使う(拡張子を信頼しない)
res.type(file.mimeType);
res.setHeader('X-Content-Type-Options', 'nosniff');
if (file.forceDownload) {
res.setHeader('Content-Disposition', 'attachment; filename="safe-name.bin"');
}
res.sendFile(path.resolve(file.storagePath));
});
エラーハンドラでもヘッダーを維持
app.use((err, req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.status(500).type('json').json({ error: 'Internal Server Error' });
});
グローバルミドルウェアを最初に登録していれば、エラーレスポンスにも通常は載る。ただし res.headersSent のタイミングには注意し、helmet を app の先頭に置くのが確実だ。
Fastify との比較(参考)
import Fastify from 'fastify';
import helmet from '@fastify/helmet';
const app = Fastify();
await app.register(helmet, { contentSecurityPolicy: false });
// @fastify/helmet も nosniff を付与
app.get('/api/data', async () => ({ ok: true }));
await app.listen({ port: 3000 });
Express では res.sendFile や res.json もミドルウェアの後に実行すればヘッダーが載る。API 専用サーバーでも JSON レスポンスに nosniff を付ける——コストゼロでスキャナースコアが上がる。
Cloudflare など CDN での設定
オリジンを触れない場合や、全レスポンスに統一したい場合は CDN 層で付与する。
Cloudflare Transform Rules(レスポンスヘッダー変換)
ダッシュボード: Rules → Transform Rules → Modify Response Header
- Operation:
Set static - Header name:
X-Content-Type-Options - Value:
nosniff
全ホスト名・全パスに適用するルールを1本作れば、オリジンがヘッダーを返さなくてもカバーできる。
Cloudflare Workers(例)
export default {
async fetch(request, env, ctx) {
const response = await fetch(request);
const headers = new Headers(response.headers);
headers.set('X-Content-Type-Options', 'nosniff');
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
},
};
オリジンと CDN の二重付与は問題にならない(同じ値の重複ヘッダー)。値が矛盾する場合のみブラウザ挙動が不定になるので、値は nosniff で統一する。
他のセキュリティヘッダーとの多層防御
nosniff 単体では反射型 XSS やクリックジャッキングは防げない。役割分担を理解してセットで入れる。
| ヘッダー・設定 | 役割 |
|---|---|
| X-Content-Type-Options: nosniff | MIME スニッフィング禁止。宣言外の script/style 実行を抑止 |
| 正しい Content-Type | nosniff の前提。種類ごとに charset も明示 |
| Content-Security-Policy | スクリプト実行元の許可リスト。nosniff と矛盾しない |
| Content-Disposition: attachment | ダウンロード専用ファイルをブラウザ内表示させない |
| Cross-Origin-Resource-Policy | 他オリジンからの読み込み制限(別軸) |
| Strict-Transport-Security | HTTPS 強制。MIME 問題とは独立 |
CSP は「どこから script を読むか」を制限し、nosniff は「このレスポンス本体を script として実行するな」を MIME レイヤーで縛る。両方設定しても矛盾しない。
全体像と nginx・Cloudflare での一括設定は セキュリティヘッダー一覧と設定ガイド を参照。CSP の具体値は CSP 設定ガイド に詳述している。XSS 全般の防御は XSS攻撃と防御の完全ガイド も合わせて読んでほしい。
導入手順と検証
DevTools の Network で主要レスポンスの Content-Type と X-Content-Type-Options の有無を確認する
HTML / JSON / 静的 JS・CSS / 画像に正しい MIME タイプが付いているか棚卸しする
404・500 エラーページにも nosniff が付くか確認する(nginx なら always)
CDN・nginx・Express ミドルウェアで nosniff を全レスポンスに付与する
ファイルアップロードがあれば許可 MIME・保存パス・attachment 方針を見直す
Mozilla Observatory や securityheaders.com でスコアと漏れを確認する
curl によるヘッダー確認
# トップページ
curl -sI https://example.com/ | grep -iE 'content-type|x-content-type'
# 404 ページ
curl -sI https://example.com/nonexistent-path-12345 | grep -iE 'content-type|x-content-type'
# 静的 JS
curl -sI https://example.com/assets/main.js | grep -i content-type
# API
curl -sI https://example.com/api/health | grep -iE 'content-type|x-content-type'
期待する出力例:
Content-Type: text/html; charset=utf-8
X-Content-Type-Options: nosniff
動作確認の具体例
- script ブロック確認 —
Content-Type: text/plainの.jsを nosniff 付きで配信し、Console に MIME エラーが出ることを確認 - JSON API —
application/json+ nosniff で<script src="/api/user">が実行されないこと - アップロード再配信 — テスト用 HTML を
text/plainで返す設定があれば、iframe 埋め込みで実行されないことを確認 - エラーページ — 意図的に 500 を発生させ、レスポンスヘッダーに nosniff があること
nosniff 導入で 既存の octet-stream script が壊れる場合がある。それは「もともと MIME が誤っていた」サインなので、正しい text/javascript へ直す。
Mozilla Observatory / securityheaders.com
外部スキャナーは次をチェックする。
X-Content-Type-Options: nosniffの有無- トップページだけでなくリダイレクト先
- HTTPS サイトでの HSTS(別項目)
スコアだけを追うのではなく、レポートに載る URL 一覧を手動で spot check する。サブドメインや API ホストが別サーバーだと、メインドメインだけ A 評価でも実は漏れがあるパターンが多い。
nosniff だけでは足りないケース
| 脅威 | nosniff の効き | 必要な別対策 |
|---|---|---|
| 反射型・ stored XSS | MIME 誤認部分のみ | 出力エスケープ、CSP、テンプレートの auto-escape |
| クリックジャッキング | 効かない | frame-ancestors / X-Frame-Options |
| MITM・HTTP 混在 | 効かない | HSTS、HTTPS リダイレクト |
| Cookie 窃取 | 間接的 | Secure、HttpOnly、SameSite |
| アップロード malware | 効かない | ウイルススキャン、実行権限のない保存先 |
| SVG 内蔵 script | 限定的 | アップロード拒否、サニタイズ、CSP |
| PDF 内蔵 JS | 限定的 | PDF サニタイズ、viewer 設定 |
PCI DSS、各種セキュリティベンチマーク、企業のベンダー監査では「セキュリティヘッダーが設定されているか」がチェック項目になる。nosniff は実装コストが最小クラスで、指摘対応の ROI が高い。
よくある実装ミスと対処
| ミス | 症状 | 対処 |
|---|---|---|
nginx で always 忘れ | 404/500 に nosniff がない | add_header ... always |
| 子 location でヘッダー消滅 | 一部パスだけスキャン不合格 | 子 location でも再宣言 |
| S3 の Content-Type 未設定 | octet-stream 配信 | アップロード時メタデータ指定 |
| express.static の .mjs | text/plain になる | mime 拡張または CDN で修正 |
| API だけヘッダー漏れ | /api/* だけ不合格 | プロキシ or ミドルウェアで統一 |
| ステージング未設定 | 本番と挙動不一致 | IaC で全環境同一ヘッダー |
関連記事
この記事は セキュリティ テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。
| トピック | 記事 |
|---|---|
| CSRF対策の実装ガイド | CSRF対策の実装ガイド|SameSite・Synchronizer Token・Laravel/Expressコード |
| Double Submit Cookie完全実装 | Double Submit Cookie完全実装|Synchronizer Token比較・SPA+JWT・サブドメイン攻撃 |
| CSP Report-Only 段階導入ガイド | CSP Report-Only 段階導入ガイド|本番を壊さずポリシーを検証する |
| セキュアなCookie設定ガイド | セキュアなCookie設定ガイド|HttpOnly・Secure・SameSiteの使い分けとCSRF対策 |
| CORSエラーの対処法 | CORSエラーの対処法|「Access-Control-Allow-Origin」で解決する |
| CORSプリフライトリクエスト | CORSプリフライトリクエスト|OPTIONSが飛ぶ条件と正しい応答 |
| ローカル開発でCORSエラーが出る原因と3つの回避策 | ローカル開発でCORSエラーが出る原因と3つの回避策 |
| OAuth2 PKCE SPA実装ガイド | OAuth2 PKCE SPA実装ガイド|Authorization Code + S256 + BFFパターン |
| Passkeys・WebAuthn実装の基礎 | Passkeys・WebAuthn実装の基礎|FIDO2・Resident Key・@simplewebauthn/server |
| JWTのデバッグ方法 | JWTのデバッグ方法|トークンの中身を安全に確認する |
| JWTの中身を確認する方法 | JWTの中身を確認する方法|改ざんが検知される仕組み |
| JWKSとJWT鍵ローテーション運用ガイド | JWKSとJWT鍵ローテーション運用ガイド|kid検証・ゼロダウンタイム・Node.js実装 |
まとめ
MIME スニッフィングは Internet Explorer 時代から知られる攻撃面だが、Content-Type の不一致とユーザー生成ファイルの再配信という形で今も残る。X-Content-Type-Options: nosniff は一行で効く防御として、正しい Content-Type・multipart の安全なパース・ファイルアップロード設計とセットで導入する。
実務では次の3点をセットに覚えるとよい。
- 全レスポンス(エラー含む)に
X-Content-Type-Options: nosniff - 種類ごとの Content-Type 表に沿った MIME と charset
- セキュリティヘッダー一覧 の CSP・HSTS 等との多層防御
まず本番のレスポンスヘッダーを DevTools で1本確認し、nosniff がなければ nginx または Express で今日中に足す——それが最短の改善だ。