はじめに
今回作成した名言Webアプリのセキュリティ実装について紹介します。
IPAが発表している「安全なウェブサイトの作り方」に則り、セキュアなコードを書くことを意識しました。この資料では、IPAが届出を受けた脆弱性関連情報を基に、 届出件数の多い脆弱性や攻撃の影響度が大きい脆弱性 を取り上げ、開発者が適切なセキュリティ対策を考慮できるようまとめられています。
反省点
開発を進める中で得た学びや反省点を整理します。
学びとしては、座学で学んだ脆弱性や脅威に対する対策を実際にコードに落とし込むことで 理解が深まった こと。また、調べながら実装を進める中で知識が増え、 新たに学びたい ことや作りたいものが次々と浮かんできました。
一方で、反省点としては、ある程度の構想を持って開発に着手したものの、途中から 行き当たりばったり の進め方になってしまった点が挙げられます。その結果、自分の作ったものに対して十分な信頼を持つことができなかったと感じました。次回はウォーターフォール型やアジャイル開発といった 明確な工程に沿って開発 を進めることで、より安定したプロジェクト管理を目指したいと考えています。
また、今回は「安全なウェブサイトの作り方」をある程度コードを書いた後に参照しましたが、今後は動作環境や環境設定などコード以外の部分も含めてCWEを参考にしながら、適切なセキュリティ対策を事前に計画していきたいと考えています。
対策
「安全なウェブサイトの作り方」のチェックリストに沿って、今回実施したセキュリティ対策を整理します。
現在実装されているセキュリティ対策
SQLインジェクション対策
このアプリケーションでは、SQLiteを使用し、プリペアドステートメントを活用しています。
cursor.execute("SELECT * FROM users WHERE username = ?", (username,))
このように プレースホルダー (?パラメータ)を使用することで、SQLインジェクションを防止しています。
また、データを追加する際にも、安全なクエリを利用しています。
con.execute("INSERT INTO users (username, password, role) VALUES (?, ?, ?)", (username, hashed_password, role))
ユーザーからの入力を サニタイズ し、ユーザーからの入力に都度Flaskの markupsafe.escape を使い escape処理 を施しました。
def sanitize_input(input_str):
sanitized_str = input_str.replace('\n', '').replace('\r', '')
return sanitized_str
データベースに書き込む処理に使う関数と、ユーザー名などをデータベースで参照するときの関数で、別々の接続を開くことで アクセス制御 します。
改善点
-
get_db_connection
にエラーハンドリングを追加し、データベース接続のエラーを適切に処理する。 - ORMを使用し、クエリの安全性をさらに高める。
- サニタイズ関数をより強化する。(英数字とスペース以外のすべての文字を除去。)
import re
def sanitize_input(input_str):
sanitized_str = re.sub(r'[^\w\s]', '', input_str)
return sanitized_str
OSコマンドインジェクション対策
シェルを起動できる言語/モジュールの使用を避けました。
環境変数を使用してAPIキーなどを取得しています。(envファイルはgitignoreに指定して公開しないようにしています)
openai.api_key = os.getenv("OPENAI_API_KEY")
AIの生成にはOpenAI APIを利用しており、環境変数の値を直接読み込んでいます。
改善点
-
攻撃面の特定と縮小: 実行するコマンドの生成に使用するデータは、最大限、外部からの制御を排除。。Web アプリケーションの場合には、 セッション状態を hidden form フィールドでクライアントに送信する代わりに、データをローカルに保存する。
-
エラーメッセージが対象となる読者にとってのみ有益な、最小限の詳細情報しか含まないようにする。(今後実装)
セッション管理の対策
アプリの secret_key はランダムなバイト列を生成して設定しました。これにより、セッションデータの改ざんを防いでいます。
app.secret_key = os.urandom(24)
セッションの有効期限が30分に設定されており、短時間でセッションが無効化されるようになっています。これにより、セッションハイジャックのリスクを低減できます。(gitのコードはテストの段階なので1分に設定してました)
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(minutes=30)
アプリがリクエストを受けるたびに、セッションを永続化する処理が実行されます。
有効期限の設定と合わせて、セッションが最後にアクティブだった時刻から30分間何もアクションがない場合にセッションが切れることになります。
@app.before_request
def make_session_permanent():
session.permanent = True
session.modified = True
セッション固定攻撃対策
セッションを再生成する関数が実装しており、ログイン時に既存のセッションをクリアし、新しいセッションとして再設定しています。
def regenerate_session():
old_data = dict(session)
session.clear()
session.update(old_data)
ログイン必須のアクセス制御
ログインしていないユーザーが特定のページにアクセスできないようにするため、デコレータ(@wraps(f))が実装しました。
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'username' not in session:
flash('セッションが切れました。再度ログインしてください。', 'error')
return redirect(url_for('login', next=request.url))
return f(*args, **kwargs)
return decorated_function
管理者専用のアクセス制御
管理者権限を持つユーザーのみが特定のページにアクセスできるようにするデコレータが実装しました。
def role_required(role):
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'username' not in session:
flash('ログインが必要です。', 'error')
return redirect(url_for('login', next=request.url))
user_role = session.get('user_role')
if user_role != role:
flash('アクセス権限がありません。', 'error')
return redirect(url_for('index'))
return f(*args, **kwargs)
return decorated_function
return decorator
ログアウト処理
ログアウト時にセッションデータを削除することで、不正なアクセスを防いでいます。
@app.route('/logout')
@login_required
def logout():
session.pop('user_id', None)
return redirect(url_for('index'))
セッションの改ざん防止
セッションに保存するデータは必要最小限にとどめ、ユーザーが直接変更できないようにしています。
session['quotes'] = fetch_random_quote()
session['chat_log'] = []
session['image_url'] = generate_image(generate_image_prompt(quotes.get('quote'), quotes.get('author_name')))
Redisを利用したセッション管理の準備
アプリではRedisをセッション管理に利用できるよう設定が準備されていますが、現在はコメントアウトしていて今後調整していきます。
app.config['SESSION_TYPE'] = 'redis'
app.config['SESSION_REDIS'] = Redis(host='localhost', port=6379)
Session(app)
レートリミットによるブルートフォース攻撃対策
短時間での過剰なログイン試行を防ぐため、1分間に10回までしかログイン試行できないように制限されています。
@limiter.limit("10 per minute")
def login():
改善点
-
Redisセッション管理の有効化
現在、セッションはサーバー内に保存されていますが、Redisを利用することで、複数のサーバー間でのセッション共有を可能にし、よりスケーラブルな設計にできます。 -
セッションIDの強制再生成
ログインやログアウト時だけでなく、管理者ページにアクセスする際にもセッションを再生成することで、セッションハイジャックのリスクをさらに減らすことができます。 -
クライアントサイドのCookie設定の強化
現在の main.py では SESSION_COOKIE_SECURE や SESSION_COOKIE_HTTPONLY を設定していません。
app.config['SESSION_COOKIE_SECURE'] = True
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
クロスサイトスクリプティング(XSS)対策
ユーザーが入力した値を適切に処理し、スクリプトの挿入を防ぐ対策を行いました。特に、Flaskの markupsafe.escape を使用して、HTMLエスケープを適用しています。
from markupsafe import escape
def sanitize_input(input_str):
sanitized_str = input_str.replace('\n', '').replace('\r', '')
return sanitized_str
username = sanitize_input(escape(request.form['username']))
password = sanitize_input(escape(request.form['password']))
HTTPレスポンスヘッダを設定し、ブラウザのXSSフィルターを有効にすることで、XSS攻撃を検出するとページのレンダリングをブロックするようにしています。
-
X-XSS-Protection: 1; mode=block
ブラウザのXSSフィルターを有効にし、XSS攻撃を検出するとページのレンダリングをブロックします。 -
Content-Security-Policy (CSP)
の設定
script-src 'self' 'unsafe-inline' となっており、インラインスクリプトの実行を許可してしまっているため、改善していきます。
def create_response(content, status=200):
response = Response(content, status=status)
response.headers['X-XSS-Protection'] = '1; mode=block'
response.headers['Content-Security-Policy'] = (
"default-src 'self'; "
"img-src 'self' data:; "
"style-src 'self' 'unsafe-inline'; "
"script-src 'self' 'unsafe-inline'; "
"reflected-xss 'block';"
)
response.headers['X-Content-Type-Options'] = 'nosniff'
return response
forms.pyでは、Flask-WTFとWTFormsを利用し、フォームのバリデーションを適用し不正なデータが送信されないようにしています。
from flask_wtf import FlaskForm
from wtforms import TextAreaField, SubmitField, StringField, PasswordField
from wtforms.validators import DataRequired, Length
class RegisterForm(FlaskForm):
username = StringField('ユーザー名', validators=[DataRequired(), Length(min=1, max=64)])
password = PasswordField('パスワード', validators=[DataRequired(), Length(min=8)])
submit = SubmitField('登録')
class LoginForm(FlaskForm):
username = StringField('ユーザー名', validators=[DataRequired(), Length(min=1, max=64)])
password = PasswordField('パスワード', validators=[DataRequired(), Length(min=8)])
app_key = StringField('アプリケーションキー', validators=[DataRequired()])
submit = SubmitField('ログイン')
class ConsultForm(FlaskForm):
user_input = TextAreaField('考えを共有してみよう', validators=[DataRequired()])
submit = SubmitField('Submit')
class CustomizeQuoteForm(FlaskForm):
customized_quote = TextAreaField('名言をカスタマイズしてみよう', validators=[DataRequired()])
submit = SubmitField('Submit')
class PromoteToAdminForm(FlaskForm):
username = StringField('Username', validators=[DataRequired()])
submit = SubmitField('Promote')
このバリデーションにより、空の入力や極端に長い文字列が入力されることを防ぎ、XSS攻撃を回避します。
改善点
現状の実装では、ユーザー名の入力に特定の文字列制限が設けられていないため、スクリプトタグを含む入力が防がれていない可能性があります。そのため、Regexp を使用して英数字とアンダースコアのみ許可することで、不正な入力を制限していきたいと思います。
改善したコード
from wtforms.validators import Regexp
class RegisterForm(FlaskForm):
username = StringField('ユーザー名', validators=[
DataRequired(),
Length(min=1, max=64),
Regexp(r'^[a-zA-Z0-9_]+$', message="ユーザー名は英数字とアンダースコアのみ使用できます")
])
password = PasswordField('パスワード', validators=[
DataRequired(),
Length(min=8)
])
また、テンプレートでユーザー入力をそのまま出力することを避ける必要があります。
return render_template("index.html", username=request.args.get('username'))
このように username を直接HTMLに埋め込むと、XSSのリスクが発生します。そのため、Jinja2の自動エスケープを利用し、ユーザー入力を適切に処理していきます。
CSRF対策
Flask-WTFの CSRFProtect を使用し、アプリ全体にCSRF保護を適用しています。
アプリの初期設定で、以下のように CSRFProtect を有効化しています。
from flask_wtf.csrf import CSRFProtect
csrf = CSRFProtect(app)
これにより、フォームを使用するリクエストに対して、CSRFトークンが正しく設定されていない場合はリクエストが拒否されます。
このようなフォームをHTMLではhidden_tag() を使うことでCSRFトークンを自動的に埋め込みます。
<form method="post">
{{ form.hidden_tag() }} ,{{ form.username.label }} ,
{{ form.username() }}, {{ form.password.label }} ,
{{ form.password() }}, {{ form.submit() }}
</form>
さらに、リクエストの Referer ヘッダーを検証することで、不正なサイトからのリクエストをブロックする仕組みも導入しています(デプロイ時の都合により今は有効化していない)。
以下のコードでは、リクエストの Referer ヘッダーをチェックし、信頼できるページからのリクエストのみを受け付けるようになっています。
コード
allowed_referers = [
"quotablemoments.com/register",
"quotablemoments.com/login",
"quotablemoments.com/logout",
"quotablemoments.com/home",
"quotablemoments.com/consult",
"quotablemoments.com/submit",
"quotablemoments.com/customize",
"quotablemoments.com/dashboard",
"quotablemoments.com/promote_to_admin",
"quotablemoments.com/index"
]
def check_referer():
referer = request.headers.get('Referer')
if referer:
for allowed_referer in allowed_referers:
if referer.startswith(allowed_referer):
return True
return False
この check_referer 関数によって、信頼できるドメインからのリクエストかどうかを検証し、不正なリクエストを排除しています。
改善点
- リクエストの Origin ヘッダーを検証することで、より厳密にリクエストの出どころをチェック。
def check_origin():
allowed_origins = ["https://quotablemoments.com"]
origin = request.headers.get("Origin")
if origin and origin not in allowed_origins:
return False
return True
- セッション管理において SameSite クッキーの適用を行うことで、クロスサイトのコンテキストからのリクエストがブロック。
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
この設定により、第三者のサイトからの不正なリクエストに対して、ブラウザがクッキーを送信しないようになります。
HTTPヘッダインジェクション
ヘッダにユーザー入力を直接適用する処理はしていません。
また以下のような HTTP レスポンスヘッダ を設定しています。
-
Strict-Transport-Security (HSTS) の適用
Strict-Transport-Security ヘッダを適用し、HTTPSを強制することで、中間者攻撃やスニッフィングのリスクを低減します。 -
X-Content-Type-Options: nosniff の適用
ブラウザがMIMEタイプを自動推測することを防ぎ、不正なコンテンツが実行されるのを防ぎます。
コード
def create_response(content, status=200):
response = Response(content, status=status)
response.headers['Content-Type'] = 'text/html; charset=utf-8'
response.headers['X-XSS-Protection'] = '1; mode=block'
response.headers['Content-Security-Policy'] = (
"default-src 'self'; "
"img-src 'self' data:; "
"style-src 'self' 'unsafe-inline'; "
"script-src 'self' 'unsafe-inline'; "
"reflected-xss 'block';"
)
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
response.headers['X-Content-Type-Options'] = 'nosniff'
return response
またサニタイズや、Refererヘッダのチェックも対策として一役買ってます。
改善点
- レスポンスヘッダにReferer-Policyを追加
Referrer-Policy を追加することで、外部サイトに対して余計な情報が漏れるのを防ぐことができます。
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
クリックジャッキング対策
X-Frame-Options ヘッダの適用
クリックジャッキングとは、攻撃者がユーザーを騙して意図しないボタンやリンクをクリックさせる攻撃手法です。これを防ぐために X-Frame-Options ヘッダを適用し、アプリのコンテンツが他のサイト内で iframe として表示されることを防いでいます。
コード
def create_response(content, status=200):
response = Response(content, status=status)
response.headers['X-Frame-Options'] = 'DENY'
response.headers['X-XSS-Protection'] = '1; mode=block'
response.headers['Content-Security-Policy'] = (
"default-src 'self'; "
"img-src 'self' data:; "
"style-src 'self' 'unsafe-inline'; "
"script-src 'self' 'unsafe-inline'; "
"frame-ancestors 'none';"
)
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
response.headers['X-Content-Type-Options'] = 'nosniff'
return response
- DENY : 他のサイトだけでなく、同じサイト内でも iframe にページを埋め込むことを禁止。
- SAMEORIGIN : 同じドメインからの iframe 埋め込みを許可し、他のサイトからの埋め込みを禁止。
- ALLOW-FROM uri : 特定のドメインのみ iframe での埋め込みを許可。
改善点
- adminユーザー専用の機能(dashboard,権限昇格)の画面に遷移する際に、改めてパスワード入力を要求する。
- 以下のJavaScriptコードをフロントエンド(HTML)に追加して、自サイトが iframe 内で開かれた場合にページをトップレベルで開き直すようにする。
if (window.top !== window.self) {
window.top.location = window.self.location;
}
- セキュリティヘッダに以下を追加し、ページの fullscreen や geolocation などのAPIの使用を制限する。
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
response.headers['Permissions-Policy'] = 'fullscreen=(self), geolocation=(self)'
バッファオーバーフロー対策
pythonは高水準プログラミング言語であり、メモリ管理を自動で行っていて、かつ今回はC言語やC++言語で実装された箇所を含むライブラリ(Mumpy,Pandas,Pytorchなど)を使用していないため対策はしていない。
次回
次回は運用レベルでの対策を施して記事にしたいと思います。
最後までお読みいただきありがとうございました!!