テックブログ

Webの基本防御を実装する:CSP/CSRF/XSSを「設定と確認」まで

Webの基本防御を実装する:CSP/CSRF/XSSを「設定と確認」まで

このページでは、Webアプリでよく出てくる3つのキーワード CSP / XSS / CSRF について、

  • どういう攻撃を防ぐものか
  • どこに・どう設定を書くのか
  • 入れたあと、ブラウザでどう確認するのか

までを、エンジニア1年目レベルでも読み切れるくらいの粒度でまとめます。

1. まず押さえる前提:何を守る話か

1-1. XSS / CSRF / CSP の関係(ざっくり対応表)

最初に、3つの関係をざっくり対応表で整理しておきます。

種別何が起きる攻撃か主な原因主な防御
XSS悪意あるスクリプトがブラウザで実行されるユーザ入力をそのままHTMLに埋め込む出力エスケープ、サニタイズ、CSP
CSRFユーザの意図しないリクエストが勝手に送られるブラウザが自動で送るCookieCSRFトークン、SameSite属性、Originチェック
CSPスクリプト・画像などの読み込み先を制限する仕組み設定不足 / ゆるいポリシーContent-Security-Policyヘッダをきちんと定義

ざっくりいうと、

  • XSS は「スクリプトを入れない・実行させない」話
  • CSRF は「勝手にリクエストさせない」話
  • CSP は「もし何か入り込んでも被害を小さくする」話

です。この記事では、この3つを一気に「設定」→「確認」までやっていきます。

1-2. 対象とするアーキテクチャ

この記事で想定するのは次のようなパターンです。

  • フロント:SPA(React/Vue 等)またはSSR(Next.js/Nuxt 等)
  • 認証:
  • Cookieベース(セッションIDをCookieで持つ)
  • トークンベース(BearerトークンをLocalStorageやメモリで持つ)

このあと、

  • Cookie認証の場合 → CSRF対策はほぼ必須
  • トークン認証の場合 → 条件次第でCSRFリスクを小さくできる

という話が出てくるので、ここは頭の片隅に置いておいてください。

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

2-1. 出力エスケープとサニタイズの使い分け

XSS対策は大きく分けて次の2段階です。

  1. そもそも 危険なタグや属性をデータとして持たない(サニタイズ)
  2. どうしても持つ場合は、描画するときにエスケープする(出力エスケープ)

React などのフレームワークは、基本的に「出力エスケープ」は自動でやってくれます。

// Reactの例
function Greeting({ name }: { name: string }) {
  return <div>こんにちは、{name}さん</div>;
}

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

一方で、マークダウンやリッチテキストなど「HTMLを入力として許可したい」場合は、ライブラリ側で「サニタイズ」を行う必要があります。例えば、

  • 許可するタグ・属性だけを残す(ホワイトリスト)
  • 危険なタグだけを削る(ブラックリスト)

といった方針で、DOMPurify などのライブラリを使うことが多いです。

// DOMPurifyを使ったサニタイズ例(TypeScript風)
import DOMPurify from "dompurify";

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

このコードでは、MarkdownをHTMLに変換したあとにDOMPurifyで危険なタグを取り除き、その結果だけを dangerouslySetInnerHTML で表示するようにしています。フレームワークの安全装置(エスケープ)を一度外す分、sanitize を必ず通してから表示することでXSSを防ぐ狙いがあります。

2-2. 危ない実装例:innerHTML・テンプレート直埋め

逆に、XSSに繋がりやすいパターンをひとつ見ておきます。

// 素のJavaScriptでの危険な例
const comment = getUserInput(); // ユーザ入力
const container = document.getElementById("comments");
container.innerHTML = `<li>${comment}</li>`;

このコードでは、ユーザ入力をそのまま innerHTML に埋め込んでいるため、comment にスクリプトタグが入ると、そのまま実行されてしまいます。テンプレートリテラルでHTMLを組み立てると便利ですが、ユーザ入力を混ぜるときは絶対に避けるようにしています。

同じように、SSR テンプレートエンジン(ejs / Handlebars など)でも、

  • <%= userInput %>(エスケープあり)
  • <%- userInput %>(エスケープなし、危険)

のような書き分けがあることが多いので、基本はエスケープありの書き方をデフォルトにしておき、どうしても生HTMLを出したい場合だけレビュー付きで許可する、といった運用にすると安全です。

3. CSPで“被害を小さくする”:導入ステップが肝

3-1. まずは Report-Only で始める

CSP(Content Security Policy)は、「どこのドメインからどんなリソース(script, img, style 等)を読み込んでいいか」をブラウザに伝える仕組みです。

いきなり本番で厳しいCSPを有効化すると、正しいスクリプトまでブロックして画面が真っ白になることがあります。
そこで、まずは Report-Only モード から始めます。

Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; report-uri /csp-report

このヘッダでは、実際にはブロックせず、「もしこのポリシーを適用した場合にNGになるもの」をレポートだけ送るようにしています。最初は Report-Only でログを眺め、「どの外部スクリプトを許可する必要があるか」を調べてから、本番の Content-Security-Policy に昇格させる流れが現実的です。

3-2. 最低限のCSP例:script-src と nonce/hash

最低限の例として、次のようなCSPを考えてみます。

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-ランダム値';
  object-src 'none';
  base-uri 'self';

このポリシーでは、

  • デフォルトでは同一オリジン('self')のみを許可
  • script-src でも同一オリジン+nonce を持つスクリプトだけを許可
  • object タグ(古いプラグイン系)は禁止

といった制限をかけています。
nonce-ランダム値 は、サーバ側でレスポンスごとにランダム文字列を生成し、HTML 内の script タグにも同じ nonce を付けることで、「サーバが発行したスクリプト以外は実行されない」ようにする仕組みです。

<script nonce="ランダム値">
  // このスクリプトはCSPで許可される
</script>

このコードでは、スクリプトに nonce を付けることで、外部からHTMLに差し込まれたインラインスクリプトが動きにくくなるようにしています。フレームワークによっては、自動的にnonceを付ける仕組みが用意されている場合もあります。

3-3. よく詰まるポイント

CSP導入でよくハマるのは次のあたりです。

  • CDNからのスクリプト(例:https://cdn.jsdelivr.net/)を許可し忘れて動かない
  • 古いコードで大量のインラインスクリプトを使っており、全部修正できない
  • Analytics(Google Analytics など)のドメインを script-src に含めるのを忘れる

最初は script-src 'self' https://example-cdn.com https://www.google-analytics.com のように必要なドメインを追加しつつ、徐々に 'unsafe-inline' を消していく、という感じのステップで進めると壊しにくくなります。

4. CSRF対策:Cookie運用なら必須になる

4-1. SameSite(Lax/Strict/None)とCSRFの効き方

CSRFは「ユーザがログインしている状態で、別サイトから勝手にリクエストを送られる」攻撃です。
ブラウザは同じドメインのCookieを自動で送ってしまうので、「単にCookieで認証しているだけ」だと、悪意あるサイトからのリクエストも正規ユーザの操作として扱ってしまいます。

そこで使うのが、Cookieの SameSite 属性です。

Set-Cookie: session=...; Path=/; Secure; HttpOnly; SameSite=Lax

この設定のCookieは、外部サイトからのフォーム送信や画像読み込みなどでは送信されにくくなるため、CSRFリスクを下げることができます。
ただし、SameSite=Lax でも防げないケースや、クロスサイトでの利用が必要なケースもあるため、多くの場合は SameSite+CSRFトークン を組み合わせます。

4-2. CSRFトークンの実装パターン

CSRFトークンには代表的に2パターンあります。

  • Synchronizer Token パターン(サーバ側にトークンを保存)
  • Double Submit Cookie パターン(Cookieとヘッダで同じ値を送る)

ここでは、Double Submit Cookie の簡単な例を見てみます。

// Node.js/Express風の擬似コード

import crypto from "crypto";
import express from "express";

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

// CSRFトークンを生成してCookieにセットする
app.get("/form", (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 });
});

// 保護したいPOSTエンドポイント
app.post("/payments", (req, res) => {
  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" });
  }

  // トークンが一致したら処理を続ける
  res.json({ ok: true });
});

このコードでは、/form にアクセスしたときに CSRFトークンを Cookie にセットし、同じ値を JSON で返しています。フロント側はこのトークンを X-CSRF-Token ヘッダに載せて /payments にPOSTします。サーバ側は、「Cookieのトークン」と「ヘッダのトークン」が一致している場合だけ処理を通すことで、外部サイトからのCSRF攻撃を防ぐ狙いがあります。

4-3. 「トークン認証ならCSRF不要」の条件整理

「セッションCookieではなく、Bearerトークンで認証しているからCSRFは関係ない」と言われることがありますが、これは条件付きです。

  • トークンをCookieに保存していないこと
  • トークンをLocalStorageなどに持ち、JSで明示的にヘッダに付けていること

この2つが満たされていると、「ブラウザが勝手に認証情報を付けてしまう」状況ではなくなるため、CSRFの攻撃面はかなり小さくなります。ただし、その代わりに XSS でトークンを盗まれるリスクが上がるので、CSPとXSS対策は一層重要になります。

5. 実装例:最小構成で入れて動作確認まで

ここでは、シンプルなNode.js/Express風構成で、

  • CSPヘッダ設定
  • CSRFトークン
  • XSSを踏まないフロントのルール

を一度に入れてみるイメージのコードを出します。

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

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

// 5-1. CSPヘッダ設定(helmetを利用)
app.use(
  helmet({
    contentSecurityPolicy: {
      useDefaults: true,
      directives: {
        "default-src": ["'self'"],
        "script-src": ["'self'"],
        "object-src": ["'none'"],
      },
    },
  })
);

// 5-2. CSRFトークン発行API
app.get("/api/csrf-token", (req, res) => {
  const token = crypto.randomBytes(32).toString("hex");
  res.cookie("csrf_token", token, {
    httpOnly: false,
    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();
}

// 5-2. 保護対象のAPI
app.post("/api/payments", verifyCsrf, (req, res) => {
  // 支払い処理...
  res.json({ ok: true });
});

// 5-3. XSSを踏まないルールでフロントを実装(イメージ)
app.get("/", (req, res) => {
  // 実際には静的ファイルやSPAを返す
  res.send("<h1>Hello</h1>");
});

app.listen(3000, () => {
  console.log("Server started on http://localhost:3000");
});

このコードでは、helmet を使って最低限のCSPをヘッダに設定しつつ、/api/csrf-token で発行したCSRFトークンを Cookie とJSONで返し、/api/payments ではミドルウェアでトークンを検証する構成にしています。CSPとCSRFが両方入っていることで、「スクリプトの読み込み先の制限」と「勝手なPOSTの防止」を同時にカバーする形を意識しています。

フロント側では、基本ルールとして「ユーザ入力を直接 innerHTML に入れない」「必要なところだけサニタイズしてから表示する」という設計方針をコンポーネントレベルで決めておくと、安全な状態を維持しやすくなります。

6. 確認方法:入れたつもりを防ぐテスト/検証

6-1. ブラウザ開発者ツールで見るポイント

設定を入れたら、まずはブラウザの開発者ツールで確認します。

  • Networkタブ:レスポンスヘッダに Content-Security-Policy が付いているか
  • Application / Storageタブ:Cookieに HttpOnly / Secure / SameSite が付いているか
  • Consoleタブ:CSP違反(Refused to load script because of...)の警告が出ていないか

まずはこれだけでも、「設定がそもそも入っているか」「CSPで何かがブロックされていないか」を確認できます。

6-2. 疑似攻撃での動作チェック

次に、簡単な疑似攻撃で動作を確認します。

  • XSS:コメント欄などに <script>alert('XSS')</script> を入れてみる
  • CSRF:別のHTMLファイルから POST を投げてみる
<!-- 簡単なCSRFテストページの例 -->
<form action="https://your-app.example.com/api/payments" method="POST">
  <input type="hidden" name="amount" value="10000" />
  <button type="submit">CSRFテスト</button>
</form>

このHTMLファイルを別ドメイン上で開いたときに、/api/payments 側で 403 になることを確認できれば、CSRFトークンのチェックが効いていると判断できます。このコードでは、あえてCSRFトークンを送らないフォームを用意することで、「トークンがないリクエストは止まる」ということを確かめる狙いがあります。

6-3. CIに組み込むアイデア

最後に、CIで自動チェックするアイデアをいくつか挙げておきます。

  • E2Eテスト(Playwright / Cypressなど)で、ログイン後のCookie属性を検証
  • HTTPヘッダチェックツール(curl+スクリプトなど)で CSP / HSTS / X-Frame-Options 等の有無を検証
  • XSS検査用の文字列をいくつか入力して、レスポンスにそのまま出てこないかを確認

「人がブラウザで確認する」のは最初の1回だけにして、あとはCIでヘッダとCookie属性をチェックするようにしておくと、「いつの間にか設定が消えていた」という事故を減らせます。

7. まとめ

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

この記事で扱った対策の優先順位を整理すると、次のようになります。

  1. XSSの基本:出力エスケープ・サニタイズ・危険なAPI(innerHTMLなど)の禁止
  2. CSP(Report-Onlyで導入):まずは壊さず観測し、徐々に厳しくする
  3. CSRF(Cookie認証なら必須):SameSite+CSRFトークンで守る

どれか1つだけで完璧、というものではなく、「複数の防御を重ねる」イメージで設計していくことが大事です。

7-2. チームで運用していくコツ

  • 「ユーザ入力をどこにどう出していいか」をコンポーネント単位でルール化する
  • テンプレートエンジンやフレームワークの「エスケープあり/なし」の記法をドキュメント化する
  • CSPの例外追加(外部CDNなど)はPRベースでレビューし、「なぜ必要か」をコメントに残す
  • セキュリティヘッダやCookie属性のチェックをCIに入れ、「入れたつもり」を防ぐ

ここまでできていれば、「とりあえず本番に出すのが怖くない最低ライン」はかなりクリアできているはずです。まずは1つの画面・1つのAPIからでよいので、CSP/CSRF/XSSをセットで入れて、ブラウザで動作を確認してみてください。

8. 参考リンク