テックブログ

CSP/CSRF/XSS防御

CSP/CSRF/XSS防御

CSP・CSRF・XSS は、どれも「Webのセキュリティ」でよく出てくる言葉ですが、
実務では「名前は知っているけど、どこに何を設定すればいいか分からない」という状態になりがちです。

この記事では、

  • 何を防ぐ仕組みなのかをざっくり整理し
  • どこにどう実装するかのパターンをコード付きで示し
  • 最後にブラウザやCIでどう確認するかまで

一気に見ていきます。

1. 全体像:XSS/CSRF/CSPは“役割が違う”

1-1. 何を防ぐのか整理する

まず名前だけ先に並べず、「何を防ぐ仕組みか」を一度整理しておきます。

  • XSS(Cross-Site Scripting)
  • 攻撃者が仕込んだ スクリプトがブラウザで実行されてしまう攻撃です。
  • メールアドレスやCookieなどの情報が盗まれたり、勝手に操作されたりします。
  • CSRF(Cross-Site Request Forgery)
  • ユーザーがログインした状態で、別サイトから勝手にリクエストを送られる攻撃です。
  • ブラウザが自動的に Cookie を付けてしまう性質を悪用します。
  • CSP(Content Security Policy)
  • ブラウザに対して「どこのドメインからどんなスクリプトを読み込んでよいか」を指示する仕組みです。
  • XSSがあっても、「変な場所からのスクリプト」や「想定外のインラインスクリプト」をブロックして被害を小さくできます。

ざっくりいうと、

  • XSS:スクリプトの注入を止める/実行させない
  • CSRF:勝手なリクエスト送信を止める
  • CSP:何か入り込んでも被害を小さくする

という役割の違いがあります。

1-2. よくある誤解を先に潰す

実務でよく聞く誤解を3つだけ挙げておきます。

  • 「CSP入れたからXSSは気にしなくていい」→ NG
    → CSPは“最後の砦”であり、エスケープやサニタイズがまず先です。
  • 「SameSite=Lax にしたから CSRF トークンはいらない」→ 危険
    → 多くのケースでリスクは減りますが、仕様変更や例外ケースもあるので、Cookie 認証なら CSRF トークンを基本とした方が安全です。
  • 「トークン認証だから安全」→ 条件付き
    → Token を Cookie に入れていたり、XSS に弱かったりすると、普通に危険です。

1-3. 対象アプリの前提を決める

防御方法はアプリの構成によって少し変わります。この記事では、次のパターンを想定して話を進めます。

  • UI:
  • SSR(Next.js など)または SPA(React / Vue など)
  • 認証:
  • Cookie 認証(セッションID or サーバー側セッション)
  • もしくは API トークン(Bearer Token)

特に XSS と CSRF は、「Cookie 認証かどうか」「どこでHTMLを組み立てているか」によって対策の形が変わるので、まず自分のアプリの前提を一度整理してみてください。

2. XSS対策:入れない・実行させない

2-1. 出力エスケープの基本

XSS対策の基本は「出力エスケープ」です。
ユーザー入力をそのまま HTML に埋め込むのではなく、特殊文字を無害な形に変換してから表示します。

文脈によって必要なエスケープが違うことを知っておくと安全です。

  • HTML本文:
    <, >, &, ", ' などを &lt; のようにエスケープ
  • HTML属性値:
    onload などのイベント属性に変な文字列を入れない
  • URL:
    hrefsrcjavascript: スキームを入れない
  • JavaScript文脈:
    <script>var name = "ユーザ入力";</script> のようなコード組み立ては避ける

React や Vue など、多くのフレームワークは、普通に変数を埋め込むぶんには自動でエスケープしてくれます。

// Reactの例:通常の埋め込みは自動でエスケープされます
function Greeting({ name }: { name: string }) {
  return <div>こんにちは、{name}さん</div>;
}

このコードでは、name<script>alert(1)</script> のような文字列が入っていても、React が自動的にエスケープしてくれるので、スクリプトとして実行されないようになっています。

2-2. サニタイズが必要なケース

一方で、Markdown や WYSIWYG エディタ経由で「HTMLを出したい」パターンもあります。
この場合、エスケープだけではダメで、次のようなケースではサニタイズが必要です。

  • ユーザーが書いた記事を HTML に変換して表示したい
  • 管理画面のブログエディタの内容をそのまま埋め込みたい
  • 外部サービスから HTML 片を受け取って表示したい

このときは「許可するタグ・属性だけを残す」という考え方でサニタイズします。
DOMPurify を使った例は次のようになります。

import DOMPurify from "dompurify";

function ArticleBody({ html }: { html: string }) {
  const safeHtml = DOMPurify.sanitize(html);
  return <div dangerouslySetInnerHTML={{ __html: safeHtml }} />;
}

このコードでは、Markdown などから生成された HTML をいったん DOMPurify に通して、危険なタグや属性を削除したあとに表示しています。React の安全装置である dangerouslySetInnerHTML を使っていますが、その前段で必ず sanitize を挟むことで、「生HTMLを表示しつつXSSも防ぐ」バランスを取っています。

2-3. 危ない実装例と置き換え

逆に、以下のような実装は XSSリスクが高いので、チームルールとして禁止しておくのが安全です。

// 危険な例:innerHTMLにユーザ入力をそのまま突っ込む
const comment = getUserInput(); // ユーザ入力
const el = document.getElementById("comments");
el.innerHTML = `<li>${comment}</li>`;

このコードでは、comment の中身がそのまま HTML として解釈されてしまうため、<script> を仕込まれると即XSSになります。これを防ぐには、「DOM APIでノードを作る」か「フレームワークのエスケープに任せる」ように書き換えます。

// 安全な書き方の一例(素のJS)
const comment = getUserInput();
const el = document.getElementById("comments");
const li = document.createElement("li");
li.innerText = comment; // textとして扱う
el.appendChild(li);

このコードでは、innerText で文字列として扱っているため、HTMLタグとして解釈されず、スクリプトが実行されないようになっています。フロントエンドでは、このように「DOM操作でテキストとして扱う」か「フレームワークに任せる」のどちらかに寄せる方針をルール化しておくと、安全な状態を保ちやすくなります。

3. CSP:Report-Onlyから始めて“壊さず強くする”

3-1. 導入手順の全体像

CSP(Content Security Policy)は、「どのリソースをどこから読み込んでよいか」を制御する仕組みです。
いきなり厳しいポリシーを本番に入れると、正しいスクリプトまでブロックして画面が真っ白になることがあります。

そこで、次のステップで導入するのがおすすめです。

  1. Report-Only モードで始める
  • Content-Security-Policy-Report-Only ヘッダを設定
  • 実際にはブロックせず、「もし本番ポリシーだったら違反になるもの」をログだけ取る
  1. 違反ログを集める
  • コンソールやレポートエンドポイントで、どのスクリプトがブロック対象になりそうかを確認
  1. ポリシーを少しずつ締める
  • 本番の Content-Security-Policy に移し、script-src などを段階的に強化

最初から「完璧なCSP」を目指すより、「壊さない範囲で少しずつ締める」というイメージを持っておくと、運用しやすくなります。

3-2. nonce/hash、strict-dynamic のイメージ

CSPでスクリプトを制御する主な方法は次の3つです。

  • nonce方式
  • レスポンスごとにランダムな文字列(nonce)を生成
  • <script nonce="abc123">... のように付けたスクリプトだけ許可
  • hash方式
  • スクリプトの中身をハッシュ化(SHA-256 など)
  • script-src 'sha256-...' のように書いて、その内容と一致するものだけ許可
  • strict-dynamic
  • nonce 付きスクリプトが動的に追加したスクリプトも許可するオプション
  • 手動でCDNドメインを全部書かなくてよくなる代わりに、設計をよく考える必要あり

簡単なCSPヘッダの例は次のようになります。

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-random123';
  object-src 'none';
  base-uri 'self';

この設定では、「同一オリジンからのスクリプト」と「指定の nonce を持つインラインスクリプト」だけを許可し、それ以外はブロックします。
nonceはレスポンスごとにランダムに変える必要があるため、SSR ではテンプレート内で nonce を埋め込む処理が必要になります。

3-3. CSPの落とし穴

CSP周りでハマりやすいポイントをいくつか挙げておきます。

  • インラインスクリプト
  • 既存のアプリで <script>alert('test')</script> のようなインラインが多いと、全部に nonce を付けるか、JSファイルに分離する必要があります。
  • CDN・外部スクリプト
  • Analytics やタグマネージャなど、外部ドメインからのスクリプトを許可し忘れると動かなくなります。
  • 広告/解析タグ
  • 広告タグは多くのドメインを叩くことがあるので、CSPを厳しくしすぎると壊れやすい部分です。
  • 動的 import
  • import() で動的にJSを読み込む場合、CSPの script-srcworker-src の設定も影響することがあります。
  • iframe
  • 埋め込みサービス(支払いフォームなど)がある場合、frame-srcchild-src を調整する必要があります。

これらは最初から全部完璧にする必要はないので、Report-Only でログを眺めながら、問題の大きそうなところから順に対処していくのが現実的です。

4. CSRF:Cookie運用なら必須(SameSiteだけに頼らない)

4-1. SameSite で何が防げるか

CSRFは、「ブラウザがCookieを自動で付けてしまう」ことを利用する攻撃です。
これに対して、Cookie の SameSite 属性を設定すると、ある程度リスクを下げられます。

  • SameSite=Lax
  • 他サイトからの POST などでは Cookie を送らないが、GETでのナビゲーションなど一部には送られる
  • SameSite=Strict
  • 完全に同一サイトからのリクエストにしか Cookie を送らない
  • SameSite=None; Secure
  • クロスサイトでも Cookie を送るが、HTTPS 必須

ただし、SameSite はブラウザの挙動や仕様変更の影響を受けますし、特殊なケース(OAuthリダイレクトなど)でうまく行かないこともあります。
Cookie認証をしているなら CSRFトークン方式も組み合わせるのが安全です。

4-2. CSRFトークン方式の選び方

代表的なパターンは次の2つです。

  • Synchronizer Token
  • サーバ側セッションにCSRFトークンを保存し、フォームやAPIリクエストと照合する方式です。
  • Double Submit Cookie
  • CSRFトークンをCookieとヘッダ(またはフォーム)で二重に送らせ、両者が一致するかを検証する方式です。

Node.js/Express風の Double Submit の例は次のようになります。

import express from "express";
import cookieParser from "cookie-parser";
import crypto from "crypto";

const app = express();
app.use(express.json());
app.use(cookieParser());

// CSRFトークン発行用エンドポイント
app.get("/csrf-token", (req, res) => {
  const token = crypto.randomBytes(32).toString("hex");

  res.cookie("csrf_token", token, {
    httpOnly: false, // JSから読み取るため
    sameSite: "Lax",
    secure: true,
  });

  res.json({ csrfToken: token });
});

// CSRF検証ミドルウェア
function verifyCsrf(req, res, next) {
  const cookieToken = req.cookies["csrf_token"];
  const headerToken = req.header("x-csrf-token");

  if (!cookieToken || !headerToken || cookieToken !== headerToken) {
    return res.status(403).json({ error: "CSRF token mismatch" });
  }
  next();
}

// 副作用のあるAPIにCSRFチェックを適用
app.post("/payments", verifyCsrf, (req, res) => {
  // 支払い処理…
  res.json({ ok: true });
});

このコードでは、/csrf-token で CSRFトークンを Cookie と JSON で返し、クライアントが X-CSRF-Token ヘッダに載せて送る構成にしています。サーバ側は Cookie のトークンとヘッダのトークンを比較し、「ブラウザが勝手に Cookie を付けたリクエストでは通らない」ようにすることで、CSRF攻撃を防ぐ狙いがあります。

4-3. API設計との合わせ方

CSRF対策は、HTTPメソッドの設計とセットで考えるとシンプルになります。

  • GET
    副作用なし(データ取得のみ)に限定し、CSRFトークンは不要とする。
  • POST / PUT / PATCH / DELETE
    状態変更や登録・削除を行うエンドポイントには CSRF トークンを必須にする。

CORS(クロスオリジン)設定と混ざることも多いですが、
「CORS は どこから呼ばせるか」「CSRF は ブラウザが勝手に Cookie を付けても安全か」という別の視点で考えると整理しやすいです。

5. 実装パターン:アプリ構成ごとの“現実解”

5-1. SSR(Next.js)のCSP適用ポイント

Next.js のような SSR フレームワークでは、レスポンスヘッダで CSP を返しつつ、テンプレート内で nonce を使うパターンがよく使われます。

// Next.js Middleware などでCSPを付与するイメージ
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import crypto from "crypto";

export function middleware(req: NextRequest) {
  const res = NextResponse.next();

  const nonce = crypto.randomBytes(16).toString("base64");

  res.headers.set(
    "Content-Security-Policy",
    [
      "default-src 'self'",
      `script-src 'self' 'nonce-${nonce}'`,
      "object-src 'none'",
      "base-uri 'self'",
    ].join("; ")
  );

  // nonceをフロントに伝える仕組み(ヘッダやcookie経由など)が別途必要
  res.headers.set("x-csp-nonce", nonce);

  return res;
}

このコードでは、Middleware内でランダムな nonce を生成し、Content-Security-Policy ヘッダに埋め込んでいます。実際には nonce をテンプレート側にも渡して <script nonce="..."> を付ける必要がありますが、「レスポンスごとにnonceを生成し、CSPとテンプレートの両方で共有する」という流れを作ることがポイントになっています。

5-2. SPA+API(BFFあり/なし)でのCSRF実装

SPA + API の構成では、

  • 認証Cookieは httpOnly で発行(JSから触らせない)
  • CSRFトークンは別Cookie or APIから取得し、JSでヘッダに付ける

という形が現実的です。フロント側のイメージコードは以下のようになります。

// SPA側:起動時にCSRFトークンを取得
async function fetchCsrfToken() {
  const res = await fetch("/csrf-token", {
    credentials: "include", // Cookie送信
  });
  const data = await res.json();
  return data.csrfToken;
}

let csrfTokenPromise = fetchCsrfToken();

export async function apiPost(path, body) {
  const csrfToken = await csrfTokenPromise;

  const res = await fetch(path, {
    method: "POST",
    credentials: "include",
    headers: {
      "Content-Type": "application/json",
      "X-CSRF-Token": csrfToken,
    },
    body: JSON.stringify(body),
  });

  if (res.status === 403) {
    // CSRFエラー時の挙動(再取得など)を統一しておく
    alert("セッションが無効になりました。ページを再読み込みしてください。");
  }

  return res;
}

このコードでは、アプリ起動時に CSRFトークンを1度取得して Promise として持ち回り、すべての POST リクエストで X-CSRF-Token ヘッダに付けるようにしています。API ロジックをこのラッパーに寄せることで、「どの画面から呼んでも CSRFトークンが必ず付く」という状態を作る意図があります。

5-3. 共通ルール化:危険API禁止とレビュー観点

アプリ全体を通して安全性を保つには、次のようなルールをチームで決めておくと効果的です。

  • 危険API禁止リスト
  • innerHTMLdocument.writedangerouslySetInnerHTML などを原則禁止
  • どうしても使う場合は、サニタイズ付きのラッパコンポーネントを通す
  • lint/レビュー観点
  • ESLint のルールやカスタムルールで、危険パターンを検出
  • PRレビュー時に「XSS/CSRF/CSP的に問題ないか」をチェック項目に入れる
  • 例外申請の運用
  • どうしても広告タグなどで緩める場合は、「なぜ必要か」「どの範囲か」をドキュメントに残す

技術的な対策だけでなく、「ルール+レビュー」で運用することで、時間が経っても防御が崩れにくくなります。

6. 確認と自動化:“入れたつもり”を潰す

6-1. ブラウザで確認するポイント

実装したら、まずはブラウザの開発者ツールで手動確認します。

  • Network タブ
  • レスポンスヘッダに Content-Security-Policy / Content-Security-Policy-Report-Only が入っているか
  • Set-Cookie の HttpOnly / Secure / SameSite が意図通りか
  • Application / Storage タブ
  • Cookie にトークンが入っていないか(Access/Refresh Token はできれば httpOnly に)
  • Console タブ
  • CSP違反(Refused to load script…)の警告が出ていないか

このあたりをチェックするだけでも、「ヘッダ入れたつもりで入っていなかった」という初歩的なミスを防げます。

6-2. 疑似攻撃テストの観点

次に、簡単な疑似攻撃を試してみます。ここで使うのは「よくあるXSS/CSRFパターン」を再現する小さなサンプルです。

  • XSS:
  • コメント欄などに <script>alert('XSS')</script> を入れてみる
  • サニタイズされている・エスケープされていることを確認
  • CSRF:
  • 別のHTMLファイルから、CSRFトークンなしのフォーム POST を投げてみる
<!-- CSRFテスト用の簡単なHTML -->
<form action="https://your-app.example.com/payments" method="POST">
  <input type="hidden" name="amount" value="10000" />
  <button type="submit">CSRFテスト送信</button>
</form>

このコードでは、別オリジンに置いたHTMLから CSRFトークンなしで POST を送っています。アプリ側で 403 になることを確認できれば、「CSRFトークンの検証が効いている」と言えます。こういった簡単な疑似攻撃を1〜2個用意しておくと、改修後の回帰確認にも使いやすくなります。

6-3. CIでの自動チェック

最後に、CIに組み込めるチェックの例をいくつか挙げます。

  • HTTPヘッダチェック
  • curl や自作スクリプトでステージング環境にリクエストし、CSP / HSTS / X-Frame-Options / Set-Cookie 属性などを機械的に検査
  • 依存ライブラリの脆弱性
  • npm audit や GitHub Dependabot などで依存の脆弱性を検査
  • E2Eテスト
  • Playwright / Cypress 等でログイン〜操作を自動化し、CSRFエラーが誤って出ていないか、CSPで正しいスクリプトがブロックされていないかを確認

「人がブラウザで確認する」のは最初だけにして、あとは CI に任せられる部分を増やしていくと、長期的に楽になります。

7. まとめ

7-1. 優先順位のおさらい

この記事で扱った対策の優先順位を、もう一度整理します。

  1. XSSの基本
  • 出力エスケープ・サニタイズ・危険なDOM API禁止をまず固める
  1. CSP(まずは Report-Only)
  • 壊さない範囲でログ収集を始め、徐々にポリシーを締める
  1. CSRF(Cookie認証なら即導入)
  • SameSiteを設定しつつ、CSRFトークン方式で確実にブロックする

7-2. ルール+検証のセットで継続可能にする

セキュリティ対策は「一度入れて終わり」ではなく、継続して守れる仕組みが必要です。

  • コード規約で「やってはいけないこと」を明文化し、lintやレビューで支える
  • ブラウザとCIで「ヘッダ・Cookie・トークンの挙動」を定期的に確認する
  • 例外(広告タグなど)は理由と範囲をドキュメント化し、むやみに増やさない

このあたりまで整えると、「新しい画面やAPIを追加しても、基本防御は自動的に効いている」状態に近づけます。まずは1ページ・1エンドポイントで構わないので、CSP/CSRF/XSSの3つをセットで試してみてください。

8. 参考リンク