はじめに
「セキュリティ対策が大事なのはわかってるけど
具体的に何をどうすればいいの?」
そう思っている方向けに
脆弱なコードと安全なコードを左右に並べて比較できるハンズオン環境を作りました
Next.js App Router + PostgreSQL で
4つの代表的な脆弱性を実際に攻撃して
防御されるところまで体験できます
学べること
- SQLインジェクション — 認証バイパス・情報漏洩
- XSS(クロスサイト・スクリプティング) — スクリプト埋め込み・Cookie窃取
- CSRF(クロスサイト・リクエスト・フォージェリ) — 罠サイト経由の不正リクエスト
- IDOR(アクセス制御の欠落) — 他人のデータへの不正アクセス
対象読者
- Webアプリ開発の経験がある(Next.js / React の基本がわかる)
- セキュリティは「なんとなく大事」程度の認識
- 座学より手を動かして覚えたい
ベースにした資料
IPA「安全なウェブサイトの作り方 改訂第7版」(2021年3月)
https://www.ipa.go.jp/security/vuln/websecurity.html
セットアップ
dockerイメージをpullして起動しちゃいましょう
curl -O https://raw.githubusercontent.com/YOUR_REPO/security-handson/main/docker-compose.hub.yml
docker compose -f docker-compose.hub.yml up
起動したら以下にアクセスしてください
| URL | 内容 |
|---|---|
| http://localhost:3000 | ダッシュボード |
| http://localhost:3000/sqli | SQLインジェクション |
| http://localhost:3000/xss | XSS |
| http://localhost:3000/csrf | CSRF |
| http://localhost:3000/idor | IDOR |
| http://localhost:4000 | 罠サイト(CSRF攻撃デモ用) |
┌─────────────────┐ ┌──────────────┐ ┌───────────────┐
│ Next.js App │────▶│ PostgreSQL │ │ 罠サイト │
│ localhost:3000 │ │ (DB) │ │ localhost:4000│
│ │ │ │ │ (nginx) │
│ /vulnerable/* │ │ users │ │ │
│ /secure/* │ │ orders │ │ csrf.html │
│ │ │ posts │ │ │
└─────────────────┘ └──────────────┘ └───────────────┘
注意: このアプリは教育目的で意図的に脆弱な実装を含んでいます
ローカル環境でのみ使用し
インターネットには絶対に公開しないでください
本題の前に: セキュリティ対策の2つの視点
IPAの資料では対策を2種類に分けています
根本的解決 — 脆弱性そのものを作り込まない実装です
これができていればその攻撃は通りません
保険的対策 — 攻撃が成功した場合の影響を軽減する仕組みです
根本的解決の漏れに対するセーフティネットになります
もう一つ大事なのは フレームワーク任せでは不十分 ということです
React / Next.js はデフォルトで多くの対策をしてくれますが
以下のケースは自分で守る必要があります
-
dangerouslySetInnerHTMLを使った瞬間 XSS対策は無効になる - API Routeにはフレームワークレベルの認可制御がない
- CSRFトークンは自分で実装が必要
この2つの視点を持って各脆弱性を見ていきましょう
1. SQLインジェクション
コードレビューをしていたらこんなコードを見つけました
const query = `SELECT * FROM users WHERE username = '${username}' AND password = '${password}'`;
const result = await pool.query(query);
ユーザー入力を直接SQLに埋め込んでいます
これはまずいですね
これは何か
CWE-89 / IPA「安全なウェブサイトの作り方」1.1節
SQL文の組み立てに問題があると
攻撃者が入力値を通じてSQL文を改変し
データベースを不正に操作できてしまう脆弱性です
起きうること:
- 個人情報の漏洩
- データの改ざん・削除
- 認証バイパスによる不正ログイン
やってみよう
http://localhost:3000/sqli にアクセスしてください
Step 1: 正常なログイン
左右の「正常ログイン」プリセットを押して taro / password123 でログインします
どちらも成功します
Step 2: 認証バイパス攻撃
「認証バイパス」プリセットを押します
入力されるのは以下です
ユーザー名: ' OR '1'='1' --
パスワード: anything
左パネル(脆弱な実装)で実行されるSQLはこうなります
SELECT * FROM users WHERE username = '' OR '1'='1' --' AND password = 'anything'
-- 以降はコメント扱いなので条件は '' OR '1'='1' になります
これは常にtrueです
全ユーザーの情報が返ってしまいます
右パネル(安全な実装)ではこうなります
SELECT * FROM users WHERE username = $1 AND password = $2
パラメータ: ["' OR '1'='1' --", "anything"]
入力値はあくまで「値」として扱われSQL構文にはなりません
ログインは失敗します
Step 3: admin奪取も試してみる
「admin奪取」プリセットで admin' -- を入力すると
左パネルではパスワードなしでadminとしてログインできてしまいます
何が起きたのか
// 脆弱: 文字列結合でSQLを組み立てている
const query = `SELECT * FROM users WHERE username = '${username}' AND password = '${password}'`;
const result = await pool.query(query);
// 安全: プレースホルダ(パラメータ化クエリ)
const query = "SELECT * FROM users WHERE username = $1 AND password = $2";
const result = await pool.query(query, [username, password]);
違いはシンプルです
入力値をSQL構文として解釈させるか
純粋なデータとして渡すか
プレースホルダを使えばどんな入力でもSQLの一部にはなりません
対策
根本的解決: SQL文の組み立ては全てプレースホルダで行います
文字列結合は理由を問わず避けてください
保険的対策:
- エラーメッセージをそのまま返さない(DB構造のヒントになる)
- DBアカウントに必要最小限の権限を与える
ORMを使う場合も油断禁物です
Prismaの $queryRawUnsafe のように生のSQLを扱うAPIには注意が必要です
2. クロスサイト・スクリプティング(XSS)
続いて見つけたのはこのコードです
<div dangerouslySetInnerHTML={{ __html: post.content }} />
ユーザーの投稿内容をHTMLとしてそのまま描画しています
名前からして dangerous なのに使っちゃってますね
これは何か
CWE-79 / IPA「安全なウェブサイトの作り方」1.5節
出力処理に問題があると
攻撃者がスクリプトを埋め込み
閲覧したユーザーのブラウザで不正なスクリプトが実行される脆弱性です
IPAの届出統計では全体の約5割がXSSで最も多い脆弱性です
起きうること:
- Cookie窃取(セッションハイジャック)
- 偽ページの表示(フィッシング)
- 利用者の操作の乗っ取り
やってみよう
http://localhost:3000/xss にアクセスしてください
Step 1: 普通の投稿
「普通の投稿」プリセットでテキストを投稿します
左右どちらも普通に表示されます
Step 2: スクリプトを埋め込む
「スクリプト埋め込み」プリセットを押します
<script>alert("XSS攻撃成功!")</script>
左パネル(脆弱な実装)では alert が実行されます
dangerouslySetInnerHTML がHTMLをそのまま解釈するためです
右パネル(安全な実装)では <script>... がテキストとして表示されるだけです
ReactのJSXによる自動エスケープが効いています
Step 3: もっとリアルな攻撃
「Cookie窃取」プリセットを試してください
<img src=x onerror="alert(document.cookie)">
<script> タグをフィルタするだけでは防げません
イベントハンドラ経由の攻撃パターンもあります
何が起きたのか
// 脆弱: dangerouslySetInnerHTML でユーザー入力をHTML解釈
<div dangerouslySetInnerHTML={{ __html: post.content }} />
// 安全: React の自動エスケープに任せる
<p>{post.content}</p>
Reactは {変数} で出力すれば自動的にHTMLエスケープしてくれます
< は < に > は > に変換されるので
スクリプトはただの文字列になります
対策
根本的解決: 出力する全ての要素にエスケープ処理を施します
Reactなら通常のJSX出力({変数})を使ってください
dangerouslySetInnerHTML にユーザー入力を渡してはいけません
保険的対策:
- CSP(Content-Security-Policy)ヘッダでインラインスクリプトを制限する
- CookieにHttpOnly属性を付与してJSからのアクセスを禁止する
Reactでも以下のケースはXSSが発生するので要注意です
-
dangerouslySetInnerHTML— ユーザー入力を渡さない -
hrefにjavascript:スキーム —<a href={userInput}>は危険 - SSR時に生のHTMLを組み立てる場合
3. CSRF(クロスサイト・リクエスト・フォージェリ)
次はAPI Routeです
プロフィール更新のコードを見てみましょう
export async function POST(request) {
const session = getSession(request);
if (!session) return unauthorized();
const { displayName, address } = await request.json();
// ログイン済みなら無条件で更新
await db.query("UPDATE profiles SET ...", [displayName, address, session.userId]);
}
ログイン確認はしていますが
そのリクエストが本人の意思かどうかは確認していません
これは何か
CWE-352 / IPA「安全なウェブサイトの作り方」1.6節
ログイン中のユーザーが罠サイトを踏むと
ユーザーの意図しないリクエストが勝手に送信されてしまう脆弱性です
攻撃者はパスワードを知る必要がないのがポイントです
起きうること:
- 設定の不正変更(パスワード変更や退会処理)
- 不正な送金・購入
- 掲示板への不適切な書き込み
やってみよう
http://localhost:3000/csrf にアクセスしてください
Step 1: ログイン
左右の「taroでログイン」ボタンを押します
Step 2: 正常なプロフィール更新
表示名や住所を変えて「プロフィール更新」を押します
左右どちらも正常に動きます
Step 3: 罠サイトを開く
左パネルの「罠サイトを開く(localhost:4000)」をクリックしてください
「おめでとうございます!」という怪しいページが開きますが
ページを開いた瞬間に攻撃が実行されています
裏ではこんなコードが走っています
fetch("http://localhost:3000/api/vulnerable/csrf/profile", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include", // ← ログイン中のCookieが自動送信される
body: JSON.stringify({
displayName: "ハッキングされました",
address: "攻撃者が書き換えた住所"
})
});
credentials: "include" がミソです
ブラウザはCookieを自動的に付与するので
ログイン中ならリクエストが通ってしまいます
Step 4: 結果を確認
http://localhost:3000/csrf に戻って左パネルを見ると
表示名が「ハッキングされました」に変わっています
右パネル(安全な実装)は変わっていません
何が起きたのか
// 脆弱: CSRFトークンの検証がない
export async function POST(request) {
const session = getSession(request);
if (!session) return unauthorized();
const { displayName, address } = await request.json();
await db.query("UPDATE profiles SET ...", [displayName, address, session.userId]);
}
// 安全: CSRFトークンを検証
export async function POST(request) {
const session = getSession(request);
if (!session) return unauthorized();
const { displayName, address, csrfToken } = await request.json();
if (!validateCsrfToken(session.token, csrfToken)) {
return forbidden("CSRFトークンが無効");
}
await db.query("UPDATE profiles SET ...", [displayName, address, session.userId]);
}
CSRFトークンはセッションに紐づいた秘密の値で
正規のページにだけ埋め込まれます
罠サイトからはこのトークンを取得できないので攻撃が成立しなくなります
対策
根本的解決: CSRFトークンをページに埋め込みリクエスト時にサーバー側で検証します
トークンは暗号論的擬似乱数生成器で生成しセッションに紐づけて管理します
保険的対策:
- 重要な操作時にメールで自動通知する
- SameSite Cookie属性をStrictに設定する
- Refererヘッダを検証する
Next.jsの Server Actions はデフォルトで一定のCSRF保護がありますが
API Routeを直接使う場合は自分でトークン検証を実装する必要があります
4. アクセス制御の欠落(IDOR)
最後はこれです
注文情報を取得するAPIを見てみましょう
export async function GET(request) {
const session = getSession(request);
if (!session) return unauthorized();
const userId = request.searchParams.get("user_id");
const orders = await db.query("SELECT * FROM orders WHERE user_id = $1", [userId]);
return Response.json({ orders });
}
認証はしています
SQLインジェクション対策もしています
でも URLの user_id を別の値に変えたらどうなるでしょう?
これは何か
CWE-264 / IPA「安全なウェブサイトの作り方」1.11節
ログイン機能はあるのに
「誰が何にアクセスできるか」という認可制御が抜けている脆弱性です
IDOR(Insecure Direct Object Reference)とも呼ばれます
URLのIDを書き換えるだけで他人のデータにアクセスできてしまう
シンプルだけど深刻な問題です
起きうること:
- 他人の個人情報・注文情報の閲覧
- 他人のデータの改ざん・削除
- 管理者権限の不正取得
やってみよう
http://localhost:3000/idor にアクセスしてください
Step 1: ログイン
左右の「taro(user_id=1)でログイン」ボタンを押します
Step 2: 自分のデータを確認
「自分 (user_id=1)」を選択して「注文情報を取得」を押します
自分の注文が表示されます
Step 3: 他人のデータにアクセス
「他人 (user_id=2)」に切り替えて「注文情報を取得」を押します
左パネル(脆弱な実装)ではhanakoの注文情報が表示されてしまいます
デバッグ情報に「他人のデータにアクセスしています」と出ます
右パネル(安全な実装)では 403 Forbidden が返されます
ログイン中のuser_idとリクエストのuser_idが一致しないためです
Step 4: adminのデータも覗いてみる
「admin (user_id=3)」に切り替えると
左パネルでは管理者の注文情報(サーバーラック 30万円)まで丸見えです
何が起きたのか
// 脆弱: URLパラメータをそのまま使用
const userId = request.searchParams.get("user_id");
const orders = await db.query(
"SELECT * FROM orders WHERE user_id = $1",
[userId] // ← URLの値をそのまま使用
);
// 安全: セッションのuser_idと照合
const userId = request.searchParams.get("user_id");
if (Number(userId) !== session.userId) {
return forbidden("アクセス権限がありません");
}
const orders = await db.query(
"SELECT * FROM orders WHERE user_id = $1",
[session.userId] // ← セッションの値を使用
);
外部から渡された user_id をそのまま信用するか
セッションと照合するか
たった数行の差ですがこの差が情報漏洩を防ぎます
対策
根本的解決: 認証に加えて認可制御を実装します
外部パラメータでDB操作する場合
そのパラメータがログインユーザーに許可されたものか毎回確認してください
理想はセッションからユーザーIDを取得し外部パラメータに依存しない設計です
API Routeでは全エンドポイントで以下を徹底します
- セッションからユーザーIDを取得する
- リクエストのリソースに対するアクセス権限を確認する
- 権限がなければ 403 を返す
まとめ
対策早見表
| 脆弱性 | 根本的解決 | やりがちな失敗 |
|---|---|---|
| SQLインジェクション | プレースホルダを使う | 文字列結合でSQLを組み立てる |
| XSS | 出力時にエスケープする |
dangerouslySetInnerHTML にユーザー入力を渡す |
| CSRF | CSRFトークンを検証する | ログイン状態のみで処理を許可する |
| IDOR | 認可制御を実装する | URLパラメータのIDを無検証で使う |
今回扱わなかった脆弱性
IPA資料では全11種類の脆弱性を解説しています
残り7つも把握しておくと良いです
- OSコマンドインジェクション
- ディレクトリトラバーサル
- HTTPヘッダインジェクション
- メールヘッダインジェクション
- クリックジャッキング
- バッファオーバーフロー
- セッション管理の不備
大事なこと
-
根本的解決を開発段階で実装する
後からの修正はコストが高いです -
フレームワークの保護範囲を正確に理解する
「Reactだから安全」ではありません -
外部からの入力を信用しない
URL・フォーム・Cookie全て検証してください
参考資料
ハンズオンのソースコードはGitHubで公開しています
https://github.com/YOUR_REPO/security-handson
間違いや改善点があればIssueやPRで教えてください
