目次

「セキュリティは機能ではない」——これはセキュリティ業界の有名な言葉だ。セキュリティは製品や機能として「追加」できるものではなく、設計と実装のあらゆる段階で組み込まれるべきものだ。本稿では、Web アプリケーションの主要な脆弱性の仕組みと、実装レベルの対策を体系的に解説する。

Web セキュリティの 3 つの基本原則

原則説明実装例
機密性許可されたユーザーのみがデータにアクセス可能認証、認可、暗号化
完全性データが改ざんされていないことの保証署名、ハッシュ、入力検証
可用性必要な時にシステムが利用可能DDoS 対策、バックアップ

1. XSS(Cross-Site Scripting)

1.1 XSS の仕組み

XSS は悪意のあるスクリプトを Web ページに埋め込む攻撃だ。

攻撃者 → 悪意のあるスクリプトを掲示板に投稿
              ↓
被害者 → 投稿を閲覧 → スクリプトが実行
              ↓
        Cookie 盗難、セッションハイジャック

1.2 XSS の種類

種類説明検出の難易度
反射型リクエストにスクリプトを含め、その場で実行比較的容易
蓄積型データベースに保存し、他ユーザー表示時に実行困難
DOM ベースクライアントサイドの DOM 操作で発生非常に困難

1.3 脆弱なコード例

# ❌ 脆弱なコード:ユーザー入力をそのまま表示
from flask import Flask, request

app = Flask(__name__)

@app.route('/search')
def search():
    query = request.args.get('q')
    # ユーザー入力をエスケープせずに HTML に埋め込み
    return f'''
    <html>
        <body>
            <h1>検索結果:{query}</h1>
        </body>
    </html>
    '''

# 攻撃例:?q=<script>alert(document.cookie)</script>
// ❌ 脆弱なコード:innerHTML でユーザー入力表示
function displayComment(comment) {
    document.getElementById('comment-section').innerHTML = comment;
}

// 攻撃例:comment = '<img src=x onerror="alert(document.cookie)">'

1.4 対策:エスケープ

# ✅ 安全なコード:HTML エスケープ
from flask import Flask, request, escape
from markupsafe import Markup

app = Flask(__name__)

@app.route('/search')
def search():
    query = request.args.get('q')
    # HTML 特殊文字をエスケープ
    safe_query = escape(query)
    return f'''
    <html>
        <body>
            <h1>検索結果:{safe_query}</h1>
        </body>
    </html>
    '''

# エスケープされる文字:
# < → &lt;
# > → &gt;
# & → &amp;
# " → &quot;
# ' → &#x27;
# 包括的な XSS 対策ミドルウェア
from functools import wraps
import re

def escape_html(text):
    """HTML エスケープ関数"""
    if text is None:
        return None
    escape_map = {
        '&': '&amp;',
        '<': '&lt;',
        '>': '&gt;',
        '"': '&quot;',
        "'": '&#x27;',
    }
    result = str(text)
    for char, escaped in escape_map.items():
        result = result.replace(char, escaped)
    return result

def sanitize_input(text, allowed_tags=None):
    """入力サニタイズ(許可タグのみ許可)"""
    if text is None:
        return None

    # 許可タグが指定されていれば、それ以外を削除
    if allowed_tags:
        # 単純な実装:タグを全て削除
        text = re.sub(r'<[^>]*>', '', text)

    return escape_html(text)

# 使用例
@app.route('/comment')
def comment():
    user_input = request.args.get('text')
    safe_input = sanitize_input(user_input)
    return f'<p>{safe_input}</p>'

1.5 フレームワークの自動エスケープ

現代のフレームワークはデフォルトでエスケープされる:

# Flask: {{ variable }} は自動エスケープ
@app.route('/template')
def template():
    return render_template('search.html', query=request.args.get('q'))

# template.html
<h1>検索結果{{ query }}</h1>  {# 自動エスケープされる #}
// React: JSX は自動エスケープ
function Comment({ text }) {
    return <div>{text}</div>;  // 自動エスケープ
}

// ❌ 危険:dangerouslySetInnerHTML
<div dangerouslySetInnerHTML={{__html: userHtml}} />  // 使用しない

1.6 Content Security Policy (CSP)

CSP はスクリプトの実行ソースを制限する追加防御層だ。

# CSP ヘッダーの設定
@app.after_request
def add_security_headers(response):
    # スクリプトは自分自身のドメインのみ許可
    response.headers['Content-Security-Policy'] = (
        "default-src 'self'; "
        "script-src 'self'; "
        "style-src 'self' 'unsafe-inline'; "
        "img-src 'self' data: https:; "
        "frame-ancestors 'none';"
    )
    response.headers['X-Content-Type-Options'] = 'nosniff'
    response.headers['X-Frame-Options'] = 'DENY'
    return response
CSP ディレクティブ説明
default-src 'self'デフォルトで自分自身のドメインのみ
script-src 'self'スクリプトは自分自身のみ
style-src 'self' 'unsafe-inline'インラインスタイルも許可
img-src 'self' data:自分自身と data URI を許可
frame-ancestors 'none'クリックジャッキング防止

2. SQL インジェクション

2.1 SQL インジェクションの仕組み

SQL インジェクションはユーザー入力を SQL 文に埋め込む攻撃だ。

攻撃者 → ログインフォームに "' OR '1'='1" を入力
              ↓
脆弱な SQL: SELECT * FROM users WHERE user='admin' AND pass='' OR '1'='1'
              ↓
認証バイパス:全ユーザーでログイン成功

2.2 脆弱なコード例

# ❌ 脆弱なコード:文字列結合で SQL 生成
import sqlite3

def login(username, password):
    conn = sqlite3.connect('database.db')
    cursor = conn.cursor()

    # ユーザー入力を直接 SQL に埋め込み
    query = f"SELECT * FROM users WHERE username='{username}' AND password='{password}'"
    cursor.execute(query)

    user = cursor.fetchone()
    conn.close()
    return user is not None

# 攻撃例:username="' OR '1'='1" --"
# 生成される SQL:
# SELECT * FROM users WHERE username='' OR '1'='1' --' AND password=''
# ❌ 他の脆弱な例:検索機能
def search_products(search_term):
    conn = sqlite3.connect('database.db')
    cursor = conn.cursor()

    query = "SELECT * FROM products WHERE name LIKE '%" + search_term + "%'"
    cursor.execute(query)

    return cursor.fetchall()

# 攻撃例:search_term="'; DROP TABLE products; --"
# 生成される SQL:
# SELECT * FROM products WHERE name LIKE '%'; DROP TABLE products; --%'

2.3 対策:パラメータ化クエリ

# ✅ 安全なコード:パラメータ化クエリ(プレースホルダー使用)
import sqlite3

def login(username, password):
    conn = sqlite3.connect('database.db')
    cursor = conn.cursor()

    # プレースホルダー(?)を使用
    query = "SELECT * FROM users WHERE username=? AND password=?"
    cursor.execute(query, (username, password))

    user = cursor.fetchone()
    conn.close()
    return user is not None

# プレースホルダーの構文:
# SQLite: ?
# PostgreSQL: %s または $1, $2
# MySQL: %s
# ✅ PostgreSQL の例
import psycopg2

def get_user(user_id):
    conn = psycopg2.connect('dbname=mydb')
    cursor = conn.cursor()

    # %s プレースホルダー
    query = "SELECT * FROM users WHERE id = %s"
    cursor.execute(query, (user_id,))

    return cursor.fetchone()

# ✅ MySQL の例(pymysql)
import pymysql

def search_products(search_term):
    conn = pymysql.connect(db='mydb')
    cursor = conn.cursor()

    query = "SELECT * FROM products WHERE name LIKE %s"
    cursor.execute(query, (f'%{search_term}%',))

    return cursor.fetchall()

2.4 ORM の使用

ORM を使うと自動的にパラメータ化される:

# ✅ SQLAlchemy の使用
from sqlalchemy import create_engine, Column, String
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker

Base = declarative_base()

class User(Base):
    __tablename__ = 'users'
    id = Column(String, primary_key=True)
    password = Column(String)

def login(username, password):
    engine = create_engine('sqlite:///database.db')
    Session = sessionmaker(bind=engine)
    session = Session()

    # ORM は自動的にパラメータ化
    user = session.query(User).filter(
        User.username == username,
        User.password == password
    ).first()

    return user is not None

2.5 権限の最小化

データベースユーザーの権限を必要最小限に制限:

-- ❌ 危険:superuser でアプリケーション実行
-- 何らかの注入攻撃で全テーブル削除可能

-- ✅ 安全:専用ユーザーを作成、権限制限
CREATE USER app_user WITH PASSWORD 'secure_password';
GRANT SELECT, INSERT, UPDATE ON users TO app_user;
GRANT SELECT ON products TO app_user;
-- DROP, DELETE, ALTER は付与しない

3. CSRF(Cross-Site Request Forgery)

3.1 CSRF の仕組み

CSRF は認証済みユーザーのブラウザに悪意のあるリクエストを送信させる攻撃だ。

攻撃者 → 悪意のある Web サイトに被害者を誘導
              ↓
被害者のブラウザ → 認証済み Cookie を含むリクエストを自動送信
              ↓
サーバー → リクエストを正当と判断、処理実行
              ↓
        不正な送金、パスワード変更など

3.2 脆弱なコード例

# ❌ 脆弱なコード:CSRF トークンなし
@app.route('/transfer', methods=['POST'])
def transfer():
    if not current_user.is_authenticated:
        return redirect('/login')

    to_account = request.form['to_account']
    amount = request.form['amount']

    # CSRF 保護なし:Cookie さえあればどのサイトからもリクエスト可能
    db.execute(
        "UPDATE accounts SET balance = balance - ? WHERE user_id = ?",
        (amount, current_user.id)
    )
    db.execute(
        "UPDATE accounts SET balance = balance + ? WHERE account = ?",
        (amount, to_account)
    )

    return 'Transfer complete'
<!-- 攻撃者のサイト:悪意のあるフォーム -->
<html>
<body onload="document.forms[0].submit()">
    <form action="https://bank.example.com/transfer" method="POST">
        <input type="hidden" name="to_account" value="attacker_account">
        <input type="hidden" name="amount" value="1000000">
    </form>
</body>
</html>

<!-- 被害者がこのサイトを訪れ、bank.example.com にログイン済みだと… -->

3.3 対策:CSRF トークン

# ✅ 安全なコード:CSRF トークンを使用
import secrets
from functools import wraps

def generate_csrf_token():
    """CSRF トークン生成"""
    return secrets.token_hex(32)

def validate_csrf_token(token, session_token):
    """CSRF トークン検証"""
    return secrets.compare_digest(token, session_token)

@app.before_request
def load_csrf_token():
    """リクエスト前に CSRF トークンをセッションに保存"""
    if 'csrf_token' not in session:
        session['csrf_token'] = generate_csrf_token()

@app.route('/transfer', methods=['GET', 'POST'])
def transfer():
    if not current_user.is_authenticated:
        return redirect('/login')

    if request.method == 'GET':
        # フォーム表示(CSRF トークンを含む)
        return render_template('transfer.html', csrf_token=session['csrf_token'])

    # POST: CSRF トークン検証
    submitted_token = request.form.get('csrf_token')
    if not validate_csrf_token(submitted_token, session['csrf_token']):
        abort(403)  # トークン不一致

    to_account = request.form['to_account']
    amount = request.form['amount']

    # 送金処理
    db.execute(
        "UPDATE accounts SET balance = balance - ? WHERE user_id = ?",
        (amount, current_user.id)
    )
    db.execute(
        "UPDATE accounts SET balance = balance + ? WHERE account = ?",
        (amount, to_account)
    )

    return 'Transfer complete'
<!-- フロントエンド:CSRF トークンをフォームに含める -->
<form method="POST" action="/transfer">
    <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
    <input type="text" name="to_account" required>
    <input type="number" name="amount" required>
    <button type="submit">送金</button>
</form>

3.4 フレームワークの CSRF 保護

# Flask-WTF の使用
from flask_wtf import FlaskWTF
from wtforms import StringField, DecimalField, SubmitField
from wtforms.validators import DataRequired

class TransferForm(FlaskWTF):
    to_account = StringField('宛先口座', validators=[DataRequired()])
    amount = DecimalField('金額', validators=[DataRequired()])
    submit = SubmitField('送金')

@app.route('/transfer', methods=['GET', 'POST'])
def transfer():
    form = TransferForm()
    if form.validate_on_submit():
        # CSRF トークンは自動検証
        process_transfer(form.to_account.data, form.amount.data)
        return 'Transfer complete'
    return render_template('transfer.html', form=form)
<!-- CSRF トークンは自動埋め込み -->
{{ form.hidden_tag() }}
{{ form.to_account.label }} {{ form.to_account() }}
{{ form.amount.label }} {{ form.amount() }}
{{ form.submit() }}
# SameSite 属性の設定
@app.after_request
def set_same_site_cookie(response):
    # Strict: 同一サイトからのリクエストのみ Cookie 送信
    # Lax: トップレベル遷移は許可
    # None: 他サイトからも許可(HTTPS 必須)
    response.set_cookie(
        'session_id',
        session_id,
        httponly=True,
        secure=True,  # HTTPS のみ
        samesite='Lax'  # または 'Strict'
    )
    return response
SameSite 値動作使用例
Strict同一サイトからのリクエストのみ Cookie 送信銀行、決済
Laxトップレベル遷移は許可、サブリクエストはブロック一般的な Web アプリ
None他サイトからも Cookie 送信(HTTPS 必須)他サイト連携機能

4. クリックジャッキング

4.1 クリックジャッキングの仕組み

クリックジャッキングは透明な iframe で正当な UI を隠蔽する攻撃だ。

攻撃者のサイト:
┌─────────────────────────────┐
│  ここをクリックして賞品ゲット! │
│  ┌─────────────────────┐   │
│  │ 透明な iframe        │   │
│  │ ┌─────────────────┐ │   │
│  │ │ 銀行の「送金」  │ │   │
│  │ │      ボタン     │ │   │
│  │ └─────────────────┘ │   │
│  └─────────────────────┘   │
└─────────────────────────────┘

ユーザーは「賞品ゲット」ボタンを押したつもりで「送金」ボタンを押してしまう

4.2 対策:X-Frame-Options

# X-Frame-Options ヘッダーの設定
@app.after_request
def set_x_frame_options(response):
    # DENY: 全ての iframe 表示を禁止
    # SAMEORIGIN: 同一ドメインからのみ許可
    # ALLOW-FROM uri: 特定のドメインのみ許可
    response.headers['X-Frame-Options'] = 'DENY'
    return response
説明
DENY全ての iframe 表示を禁止
SAMEORIGIN同一ドメインからの iframe のみ許可
ALLOW-FROM https://example.com特定のドメインのみ許可

4.3 Content Security Policy frame-ancestors

# CSP frame-ancestors でより詳細な制御
@app.after_request
def set_csp_frame_ancestors(response):
    response.headers['Content-Security-Policy'] = (
        "frame-ancestors 'self' https://trusted-partner.com;"
    )
    return response

5. セッション管理

5.1 セッションハイジャック

セッション ID が盗まれると、攻撃者はユーザーに「なりすまし」可能だ。

攻撃手法:
1. XSS で Cookie 盗難(document.cookie)
2. ネットワーク盗聴(HTTPS 未使用)
3. セッション ID 推測(弱な乱数)
4. セッションフィクセーション(固定化攻撃)

5.2 安全なセッション実装

# ✅ 安全なセッション管理
import secrets
from datetime import datetime, timedelta

def create_session(user_id):
    """安全なセッション ID 生成"""
    # 暗号論的乱数生成
    session_id = secrets.token_urlsafe(32)

    # セッション情報
    session_data = {
        'user_id': user_id,
        'created_at': datetime.utcnow(),
        'expires_at': datetime.utcnow() + timedelta(hours=2),
        'ip_address': request.remote_addr,
        'user_agent': request.headers.get('User-Agent')
    }

    # データベースに保存
    db.execute(
        "INSERT INTO sessions (id, user_id, data, expires_at) VALUES (?, ?, ?, ?)",
        (session_id, user_id, json.dumps(session_data), session_data['expires_at'])
    )

    return session_id

def validate_session(session_id):
    """セッション検証"""
    session = db.execute(
        "SELECT data FROM sessions WHERE id = ? AND expires_at > ?",
        (session_id, datetime.utcnow())
    ).fetchone()

    if not session:
        return None

    session_data = json.loads(session['data'])

    # IP アドレスとユーザーエージェントの検証(オプション)
    if session_data.get('ip_address') != request.remote_addr:
        return None  # IP 不一致

    return session_data

def rotate_session_id():
    """セッション ID のローテーション(権限昇格時)"""
    old_session_id = request.cookies.get('session_id')
    if old_session_id:
        # 旧セッションを無効化
        db.execute("DELETE FROM sessions WHERE id = ?", (old_session_id,))

    # 新セッション ID 生成
    new_session_id = create_session(current_user.id)
    return new_session_id

5.3 セッションクッキーのセキュリティ設定

# セキュアな Cookie 設定
response.set_cookie(
    'session_id',
    session_id,
    httponly=True,     # JavaScript からアクセス不可(XSS 対策)
    secure=True,       # HTTPS のみ(盗聴対策)
    samesite='Lax',    # CSRF 対策
    max_age=7200,      # 2 時間で有効期限切れ
    path='/',          # パス制限
    domain='example.com'  # ドメイン制限
)
属性説明
HttpOnlyJavaScript からアクセス不可(XSS 対策)
SecureHTTPS 接続でのみ送信(盗聴対策)
SameSite他サイトからのリクエストを制限(CSRF 対策)
Max-Age / Expires有効期限を設定
PathCookie が送信されるパスを制限
DomainCookie が送信されるドメインを制限

6. パスワード管理

6.1 安全なパスワード保存

# ❌ 危険:平文保存
def register(username, password):
    db.execute(
        "INSERT INTO users (username, password) VALUES (?, ?)",
        (username, password)  # 平文!
    )

# ❌ 危険:MD5/SHA1 などの弱いハッシュ
import hashlib
hashed = hashlib.md5(password.encode()).hexdigest()

# ✅ 安全:bcrypt 使用
import bcrypt

def hash_password(password):
    """パスワードハッシュ化"""
    # ソルト自動生成
    salt = bcrypt.gensalt(rounds=12)
    hashed = bcrypt.hashpw(password.encode(), salt)
    return hashed

def verify_password(password, hashed):
    """パスワード検証"""
    return bcrypt.checkpw(password.encode(), hashed)

# 使用例
hashed = hash_password("my_secure_password")
if verify_password("my_secure_password", hashed):
    print("ログイン成功")

6.2 パスワードポリシー

import re

def validate_password(password):
    """パスワード強度チェック"""
    errors = []

    if len(password) < 12:
        errors.append("パスワードは 12 文字以上である必要があります")

    if not re.search(r'[A-Z]', password):
        errors.append("大文字を含めてください")

    if not re.search(r'[a-z]', password):
        errors.append("小文字を含めてください")

    if not re.search(r'\d', password):
        errors.append("数字を含めてください")

    if not re.search(r'[!@#$%^&*(),.?":{}|<>]', password):
        errors.append("記号を含めてください")

    # 一般的なパスワードの除外
    common_passwords = ['password', '123456', 'qwerty', 'admin']
    if password.lower() in common_passwords:
        errors.append("一般的なパスワードは使用できません")

    return len(errors) == 0, errors

7. セキュリティヘッダー

7.1 推奨セキュリティヘッダー

@app.after_request
def set_security_headers(response):
    """セキュリティヘッダーの設定"""

    # XSS 対策
    response.headers['X-Content-Type-Options'] = 'nosniff'
    response.headers['X-Frame-Options'] = 'DENY'
    response.headers['X-XSS-Protection'] = '1; mode=block'

    # CSP
    response.headers['Content-Security-Policy'] = (
        "default-src 'self'; "
        "script-src 'self'; "
        "style-src 'self' 'unsafe-inline';"
    )

    # HTTPS 強制(HSTS)
    response.headers['Strict-Transport-Security'] = (
        'max-age=31536000; includeSubDomains; preload'
    )

    # リファラーポリシー
    response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'

    # 権限ポリシー(新しい機能の制限)
    response.headers['Permissions-Policy'] = (
        'geolocation=(), microphone=(), camera=()'
    )

    return response
ヘッダー効果
X-Content-Type-OptionsnosniffMIME タイプの自動判別を防止
X-Frame-OptionsDENYクリックジャッキング防止
X-XSS-Protection1; mode=blockブラウザの XSS フィルタ有効化
Strict-Transport-Securitymax-age=31536000HTTPS 強制(HSTS)
Content-Security-Policydefault-src 'self'リソース読み込み制限
Referrer-Policystrict-origin-when-cross-originリファラー情報制限

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

8.1 開発中のチェックリスト

チェック項目実装
全てのユーザー入力をエスケープ/サニタイズ
パラメータ化クエリまたは ORM を使用
CSRF トークンを実装
セッション ID を安全に管理
パスワードは bcrypt でハッシュ化
エラーメッセージに機密情報を含めない
認証・認可を全てのエンドポイントで確認
速率制限(レートリミット)を実装

8.2 エラーハンドリング

# ❌ 危険:詳細なエラー情報を表示
@app.errorhandler(Exception)
def handle_error(e):
    return f"エラー:{str(e)}\nスタック:{traceback.format_exc()}", 500

# ✅ 安全:一般的なエラーメッセージ
@app.errorhandler(Exception)
def handle_error(e):
    # ログには詳細を記録
    app.logger.error(f"Internal error: {str(e)}", exc_info=True)

    # ユーザーには一般的なメッセージ
    return jsonify({
        'error': 'Internal Server Error',
        'message': '予期せぬエラーが発生しました'
    }), 500

まとめ

Web セキュリティ対策の要点を整理する:

  1. XSS 対策: 入力値のエスケープ、CSP の設定
  2. SQL インジェクション: パラメータ化クエリ、ORM の使用
  3. CSRF 対策: CSRF トークン、SameSite Cookie
  4. クリックジャッキング: X-Frame-Options、CSP frame-ancestors
  5. セッション管理: 安全なセッション ID、HttpOnly/Secure/SameSite
  6. パスワード管理: bcrypt によるハッシュ化、強度ポリシー
  7. セキュリティヘッダー: HSTS、CSP、X-Content-Type-Options

セキュリティは「レイヤード防御」が基本だ。一つの対策が破られても、他の層で攻撃を食い止められるように、複数の対策を組み合わせることが重要である。

免責事項 — 掲載情報は執筆時点のものです。料金・機能は変更される場合があります。最新情報は各公式サイトをご確認ください。