目次
「セキュリティは機能ではない」——これはセキュリティ業界の有名な言葉だ。セキュリティは製品や機能として「追加」できるものではなく、設計と実装のあらゆる段階で組み込まれるべきものだ。本稿では、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>
'''
# エスケープされる文字:
# < → <
# > → >
# & → &
# " → "
# ' → '
# 包括的な XSS 対策ミドルウェア
from functools import wraps
import re
def escape_html(text):
"""HTML エスケープ関数"""
if text is None:
return None
escape_map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
}
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() }}
3.5 SameSite Cookie 属性
# 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' # ドメイン制限
)
| 属性 | 説明 |
|---|---|
HttpOnly | JavaScript からアクセス不可(XSS 対策) |
Secure | HTTPS 接続でのみ送信(盗聴対策) |
SameSite | 他サイトからのリクエストを制限(CSRF 対策) |
Max-Age / Expires | 有効期限を設定 |
Path | Cookie が送信されるパスを制限 |
Domain | Cookie が送信されるドメインを制限 |
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-Options | nosniff | MIME タイプの自動判別を防止 |
X-Frame-Options | DENY | クリックジャッキング防止 |
X-XSS-Protection | 1; mode=block | ブラウザの XSS フィルタ有効化 |
Strict-Transport-Security | max-age=31536000 | HTTPS 強制(HSTS) |
Content-Security-Policy | default-src 'self' | リソース読み込み制限 |
Referrer-Policy | strict-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 セキュリティ対策の要点を整理する:
- XSS 対策: 入力値のエスケープ、CSP の設定
- SQL インジェクション: パラメータ化クエリ、ORM の使用
- CSRF 対策: CSRF トークン、SameSite Cookie
- クリックジャッキング: X-Frame-Options、CSP frame-ancestors
- セッション管理: 安全なセッション ID、HttpOnly/Secure/SameSite
- パスワード管理: bcrypt によるハッシュ化、強度ポリシー
- セキュリティヘッダー: HSTS、CSP、X-Content-Type-Options
セキュリティは「レイヤード防御」が基本だ。一つの対策が破られても、他の層で攻撃を食い止められるように、複数の対策を組み合わせることが重要である。
免責事項 — 掲載情報は執筆時点のものです。料金・機能は変更される場合があります。最新情報は各公式サイトをご確認ください。