Python学習13日目です!
Webアプリケーションのセキュリティ対策の一つである、CSRF(クロスサイトリクエストフォージェリ)対策について学んでいきます。
CSRF攻撃とは何か?
CSRF攻撃とは、ログイン中のユーザーを騙して、知らないうちに不正なリクエストをWebサービスに送信させる攻撃です。
例えば、銀行のWebサイトにログインしたまま、別のタブで悪意のあるサイトを開いたとします。
その悪意のあるサイトに仕込まれた不正なコードによって、ブラウザが知らないうちに銀行サイトへ勝手に送金リクエストを送ってしまうといったことが起こりえます。
サービス提供側から見ると、それは正規のユーザからのリクエストに見えるため、不正な送金が実行されてしまうのです。
このように、CSRF攻撃はユーザー自身の意図しない操作で実行されてしまうため、ユーザー側の注意だけでは防ぎきれません。
開発者として、Webサービス側でCSRFトークンを導入するなどの対策を講じることが必須となります。
CSRF攻撃について、さらに詳しく知りたい方は、以下のIPA(情報処理推進機構)のWebサイトが参考になります。
https://www.ipa.go.jp/security/vuln/websecurity/csrf.html
CSRF攻撃の対策方法:CSRFトークン
CSRF攻撃を防ぐための最も一般的かつ効果的な対策は、CSRFトークンを導入することです。
CSRFトークンの仕組み
CSRFトークンは、以下のようなステップで攻撃を防ぎます。
- トークンの生成と埋め込み
- サーバーがWebページにフォームを生成する際、一意で推測不可能なランダムな文字列(トークン)を発行します。
- このトークンは、ユーザーのセッションと紐づけてサーバーに保存されるとともに、フォーム内に隠しフィールドとして埋め込まれます。
- トークンの検証
- ユーザーがフォームを送信すると、ブラウザはフォームのデータと一緒に、このCSRFトークンをサーバーに送ります。
- サーバーは、送られてきたトークンが、自身が発行しセッションに保存しておいたトークンと完全に一致するかを厳密にチェックします。
なぜCSRFトークンで攻撃を防げるのか?
この仕組みによって、攻撃者が作成した悪意のあるサイトからのリクエストは、以下の理由でブロックされます。
- トークンを知ることができない
- 正しいトークンを送れない
- サーバーが不正なリクエストを拒否
攻撃者は、サーバーが生成した一意のCSRFトークンを知ることができません。
そのため、悪意のあるサイトから送られる不正なリクエストには、正しいCSRFトークンが含まれていません。
サーバーは、CSRFトークンが一致しないリクエストを不正なものとして自動的に拒否することで、意図しない操作が実行されるのを防ぎます。
このように、CSRFトークンはWebサービスとユーザーのブラウザ間で「秘密の合言葉」のような役割を担い、「秘密の合言葉」を知らない攻撃者の成りすましを防いでくれるのです。
WTFormsとFlask-WTFを使った対策方法
Python + Flaskの環境では、WTFormsとFlask-WTFライブラリを使用することで、簡単にCSRF対策を実装できます。
必要なモジュールのインポート
CSRF対策と環境変数の安全な管理を行うために、以下のモジュールをapp.pyの先頭でインポートします。
import html import os # 追加 from flask import Flask, render_template, request from flask_wtf import FlaskForm from wtforms import TextAreaField, SubmitField from wtforms.validators import DataRequired, Length from dotenv import load_dotenv # 追加
import os
Pythonの標準ライブラリであるosモジュールをインポートします。
このモジュールを使用することで、環境変数を取得することが出来ます。
今回は、「.env」ファイルに書かれた設定値を環境変数として取得するために使用します。
今回は使用しませんが、ファイルやディレクトリを操作する機能なども使用できるようになります。
from dotenv import load_dotenv
dotenvライブラリ(モジュール)の中から、load_dotenv関数をインポートします。
load_dotenv関数は、プロジェクトのルートディレクトリにある「.env」ファイルに書かれた設定値を環境変数として読み込んでくれます。
dotenvライブラリを使用するには、python-dotenvをインストールする必要があります。
今回は、開発環境を構築する際にインストール済みです。
SECRET_KEYの設定
WTFormsとFlask-WTFでCSRF保護を有効にするには、秘密鍵(SECRET_KEY)の設定が必須で、このキーは、CSRFトークンの生成やセッションの保護に使われます。
この秘密鍵は、推測されにくい長く複雑な文字列に設定する必要があります。
もしこの鍵が短かったり単純すぎたりすると、攻撃者によって推測され、セキュリティ対策が無効化されてしまうリスクがあります。
この秘密鍵は、コードに直接書き込まず、「.env」ファイルから環境変数として読み込むようにするほうが、セキュリティ的に推奨されます。
.envファイルの作成
プロジェクトのルートディレクトリに「.env」という名前のファイルを以下の内容で作成します。
FLASK_SECRET_KEY='ここに推測されにくて長くランダムな文字列を記述'
この「.env」ファイルは、「.gitignore」に追記してGitの管理から除外しておくことで、GitHubに誤ってプッシュされるのを防げます。
SECRET_KEY(文字列)の作成方法
Pythonの標準ライブラリであるsecretモジュールを使用することで、簡単にランダムな文字列を生成することが出来ます。
- secrets.token_hex(バイト数)
- secrets.token_urlsafe(バイト数)
引数で指定したバイト数の2倍の長さのランダムな16進数文字列を生成します。
1バイトは2桁の16進数で表現されるため、引数に32バイトを指定すると2倍である64文字の文字列が生成されます。
英大文字、英小文字、数字、記号を含むURLセーフな文字列を生成します。
このとき、引数として指定したバイト数分のランダムなバイト列を生成して、Base64形式でエンコードを行います。
このエンコードの際に、元の3バイトが4文字に変換されるため、生成される文字列の長さは元のバイト数の約1.33倍になります。
生成された文字列を「.env」ファイルにコピー&ペーストして、安全な文字列をSECRET_KEYとして設定しましょう。
SECRET_KEY(文字列)の生成例
Pythonの仮想環境内での生成例です。
生成される文字列は、実行するたびに変わります。
(html_converter) $ python -c 'import secrets; print(secrets.token_hex(32))' acd2381c044ba25099894ebb7a825b859e0b072bfa4d9e884901f59d8eb26dac (html_converter) $ $ python -c 'import secrets; print(secrets.token_urlsafe(48))' ORy0etomL_rJteWjOc9M4909NxeU9SGHNhveEL1e-cxbNgbB9nb4Elj5iyBICutC
Pythonの仮想環境外(システムにインストールされたPythonを使用)であれば以下のようになります。
こちらも、生成される文字列は、実行するたびに変わります。
$ python3 -c 'import secrets; print(secrets.token_hex(32))' e424dcb48c0cb753c36cbb0dffe6b9c84a791a20f59bc8b264c5e346b21bfaad $ python3 -c 'import secrets; print(secrets.token_urlsafe(48))' VBQSFsfmeH_imZnun80bwobZyEhjbnDix_TlrgQUuNmhrQVDBYqMODG2YRyXl2St
「.env」ファイルからSECRET_KEY(秘密鍵)の内容を読み込む
前回までのapp.pyでは、SECRET_KEYの内容をコードに直接記述していましたが、これはセキュリティ上のリスクがあります。
今回は、そのSECRET_KEY(秘密鍵)の内容を「.env」ファイルから安全に読み込むように変更します。
# .envファイルから環境変数をロード load_dotenv() # 追加 # Flaskアプリケーションのインスタンスを作成 app = Flask(__name__) # 環境変数からSECRET_KEYの値を取得 app.config['SECRET_KEY'] = os.environ.get('FLASK_SECRET_KEY') # 変更
load_dotenv()
Pythonのpython-dotenvライブラリが提供する関数です。
プロジェクトのルートディレクトリにある「.env」ファイルを読み込み、そこに書かれた設定をPythonの環境変数として読み込んでくれます。
この関数は、他のコードが環境変数を読み込む前に実行されるよう、app.pyの先頭付近で呼び出すのが一般的です。
なぜ.envを使うのか?
Webアプリケーションを開発する場合に、データベースのパスワード、APIキー、Flaskの秘密鍵(SECRET_KEY)など、公開してはいけない機密情報を扱うことがあります。
もしこれらの情報をコードに直接書いてしまうと、GitHubなどのバージョン管理システムに誤ってコミットしてしまい、情報が漏洩する危険があります。
「.env」ファイルに機密情報を記述して、「.gitignore」に追記しておくことで、機密情報をコードと分離しGitの管理対象から外せます。
app.config['SECRET_KEY'] = os.environ.get('FLASK_SECRET_KEY')
環境変数「FLASK_SECRET_KEY」の値をapp.configオブジェクト内のSECRET_KEYに代入しています。
app.config['SECRET_KEY']
Flaskアプリケーションの設定項目の一つで、「app.config」という辞書のようなオブジェクトの「SECRET_KEY」というキーに、取得した値を代入しています。
「SECRET_KEY」は、その設定の中でも特に重要な項目で、CSRFトークンの生成やセッションデータの署名など、セキュリティ関連の機能で使われます。
os.environ.get('FLASK_SECRET_KEY')
「os.environ」は、Pythonの標準ライブラリであるosモジュールが提供する、環境変数を扱うためのオブジェクトです。
これを使うことで、システムに設定されている環境変数を扱うことができます。
「.get('FLASK_SECRET_KEY')」で、「os.environ」から「FLASK_SECRET_KEY」という名前の環境変数の値を取得しています。
HTMLテンプレート(index.html)での実装:CSRFトークンをフォームに組み込む
「pp.py」で「SECRET_KEY」の設定とフォームの定義が完了したので、次はHTMLテンプレートにCSRFトークンを組み込みます。
Flask-WTFを使えば、たった一行のコードを追加するだけで、HTMLテンプレート(index.html)に{{ form.csrf_token }}と記述することで、隠しフィールドとしてCSRFトークンを挿入できます。
<form method="POST"> {{ form.csrf_token }} ### 中略 ### </form>
※この部分は、前回作成した「index.html」に設定済みなので、今回は編集の必要はありませんでした。
{{ form.csrf_token }}
HTMLのフォームにCSRF(クロスサイトリクエストフォージェリ)攻撃を防ぐための隠しフィールドを自動的に挿入する役割をもっています。
{{ form.csrf_token }}の役割と仕組み
この{{ form.csrf_token }}は、Webアプリケーションのセキュリティを支える非常に重要な部分です。
- 隠しフィールドの自動挿入
テンプレートがレンダリングされる際に、以下のようなHTMLの隠しフィールド(type="hidden")を自動的に生成します。
<input id="csrf_token" name="csrf_token" type="hidden" value="[ランダムに生成された長い文字列]">
このフィールドはユーザーのブラウザには表示されませんが、フォームが送信される際には、他のデータと一緒にサーバーへ送られます。
このvalueに格納されている「ランダムに生成された長い文字列」が、CSRFトークンです。
CSRFトークンは、サーバーの「SECRET_KEY」とセッションIDやタイムスタンプなどの情報などを組み合わせて生成されます。
このプロセスにより、トークンが予測不可能になり、改ざんが非常に困難になります。
設定の反映
「app.py」と「index.html」の編集が完了しましたら、新しいセキュリティ設定を反映させる必要があります。
今回の環境では、systemdでGunicornを管理しているため、以下のコマンドでGunicornサービスを再起動します。
$ sudo systemctl restart html_converter.service
動作確認:CSRF対策の有効化をチェック
「app.py」と「index.html」の修正、そしてGunicornの再起動が完了したら、Webブラウザから実際にサービスにアクセスし、CSRF対策が正しく機能しているかを確認しましょう。
- HTML変換サービスの確認
- CSRFトークンの存在を確認
実際にHTMLのコードをフォームに入力して、問題なく変換できるのかを確認します。
Webブラウザでページのソースを表示し、fromタグの中にCSRFのトークンが表示されていることを確認します。
<h1>HTML特殊文字変換ツール</h1>
<form method="POST">
<input id="csrf_token" name="csrf_token" type="hidden" value="CSRトークンが表示される">
<div class="form-group">
<label for="html_code">変換したいHTMLコードを入力してください(最大10,000文字):</label><br>
valueには、アクセスするたびに異なるランダムな文字列が表示されるはずです。
この隠しフィールドが存在すれば、Flask-WTFがCSRFトークンを正しくフォームに組み込んでいて、CSRF対策が有効になっていることになります。
問題なければCSRF対策の実装が完了となります。
本日のまとめ:CSRF対策は必須のセキュリティ!
今回はCSRF攻撃の仕組みと、その対策方法について学びました。
正直CSRF攻撃というのは今まで知らなったのですが、実際に他サービスのHTMLソースを確認してみると、今回とは形は違いますが同じようにCSRFトークンが埋め込まれていることが確認できました。
これは、CSRF対策が多くのWebサービスで採用されている、とても基本的なセキュリティ対策の一つであることを改めて教えてくれました。
そして、WTFormsとFlask-WTFを使うことで、このCSRF対策を思っていたよりもずっと簡単に実装できることがわかりました。
CSRF対策は、Webサービスのセキュリティを確保する上で欠かせない要素ということなので、今後の開発でもこの対策を必ず実装していこうと思います。
次回の予定:レートリミットでサーバーを守る
次回は、レートリミット制限の方法について学んでいく予定です。
短時間に大量のリクエストが送られてくるDDoS攻撃や、意図しないアクセス集中からサーバーを守るために、レートリミットは不可欠な対策です。
NginxやFlask-Limiterを使った実装方法について、詳しく学んでいきます。
現時点でのapp.py
CSRF対策を行った、最新のapp.pyは以下の通りになっています。
app.py
# 必要なモジュールをインポート import html import os from flask import Flask, render_template, request from flask_wtf import FlaskForm from wtforms import TextAreaField, SubmitField from wtforms.validators import DataRequired, Length from dotenv import load_dotenv # .envファイルから環境変数をロード load_dotenv() # Flaskアプリケーションのインスタンスを作成 app = Flask(__name__) # 環境変数からSECRET_KEYの値を取得 app.config['SECRET_KEY'] = os.environ.get('FLASK_SECRET_KEY') # HTMLエスケープフォームの定義 class HtmlEscapeForm(FlaskForm): html_code = TextAreaField( '変換したいHTMLコードを入力してください(最大10,000文字):', validators=[ DataRequired(message="HTMLコードは必須です。"), Length(max=10000, message="入力されたHTMLコードが長すぎます(最大10,000文字)。") ] ) submit = SubmitField('変換') # '/' (ルートURL) にアクセスがあった場合の処理を定義 @app.route('/', methods=['GET', 'POST']) def index(): form = HtmlEscapeForm() escaped_html = None if form.validate_on_submit(): original_html_input = form.html_code.data decoded_html = html.unescape(original_html_input) escaped_html = html.escape(decoded_html) return render_template('index.html', form=form, escaped_html=escaped_html) # このファイルが直接実行された場合に開発用サーバーを起動 #if __name__ == '__main__': # app.run(debug=True)
コメント