Google App EngineでGoogle Cloud DatastoreとFlaskを利用したセキュアなサーバーを作る
前回までにGoogle Cloud Datastoreを利用した簡単なFlaskサーバーとFlaskのセキュリティを学びました。
今回はこれらを踏まえて、実際に運用できるFlaskサーバーを作りましょう。利用するデータベースはGoogle Cloud Datastore(以下Datastore)です。
今回のステップが終われば、あとは次回やるGoogle App Engineのセキュリティ設定だけです。
前回作ったコード
前回作ったmain.py
は以下の通りです。
# coding: UTF-8 import flask from google.appengine.ext import ndb app = flask.Flask(__name__) class User(ndb.Model): name = ndb.StringProperty() mail = ndb.StringProperty() password = ndb.StringProperty() @app.route('/') def toppage(): return flask.render_template('toppage.html') @app.route('/showname') def showname(): name = flask.request.args.get('name') return flask.render_template('showname.html', name=name) @app.route('/database/new', methods=['POST']) def database_new(): name = flask.request.form['name'] mail = flask.request.form['mail'] password = flask.request.form['password'] user = User(name=name, mail=mail, password=password) user.put() @app.route('/database/get', methods=['POST']) def database_get(): name = flask.request.form['name'] password = flask.request.form['password'] query = User.query(ndb.AND(User.name == name, User.password == password)) result = query.get() if result is None: return 'ユーザーネームとパスワードの組み合わせが正しくありません' else: return result.mail @app.route('/database/reflesh', methods=['POST']) def database_reflesh(): username = flask.request.form['user'] user_entity = User.query(User.name == username).get() #有効なEntityが返ってきた場合はリクエストに含まれるパラメータそれぞれについてPropertyを更新 if user_entity: for key, value in flask.request.form.items(): if key != 'user': if hasattr(user_entity, key): setattr(user_entity, key, value) user_entity.put() else: return '該当するユーザーがみつかりませんでした'
まだセキュリティ対策はなにもしていません。
データベースを利用しないページ
一番最初にFlaskアプリの挙動確認のために作ったページです。
/
:トップページ(用意したHTMLを出力するだけ)/showname
:GETリクエストのクエリパラメータを画面に出力するページ
データベースを利用するページ
Datastoreにアクセスします。
/database/new
:POSTリクエストでDatastoreに新しいEntityを追加/database/get
:POSTリクエストでDatastoreからEntityのPropertymail
を取得/database/reflesh
:POSTリクエストでDatastoreのEntityの内容を更新
レスポンスヘッダの追加
さて、最初にセキュリティに関連するレスポンスヘッダを用意しましょう。(内容は前回をみてください)
flask.make_response(レスポンスボディ)
関数を利用してレスポンスインスタンスを作成し、そこにヘッダを追加します。
def prepare_response(data): response = flask.make_response(data) response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains' response.headers['Content-Security-Policy'] = 'default-src \'self\'' response.headers['X-Content-Type-Options'] = 'nosniff' response.headers['X-Frame-Options'] = 'DENY' response.headers['X-XSS-Protection'] = '1; mode=block' return response
そうしたら、これまで直接return
していた出力(レスポンスボディ)を今作成したprepare_response
関数の引数に渡してレスポンスインスタンスを作成し、それをreturn
するように変更します。
ステータスコードの追加
ついでにレスポンスにステータスコードも追加しましょう。
ステータスコードとは'200 OK'とか'404 Not Found'というやつです。404は普段のブラウジングでもたまに見るかと思います。これはレスポンスの意図を端的に伝えるもので、HTTPの仕様で番号は決まっています。
こちらのページを参考に、それぞれのレスポンスにステータスコードを設定しましょう。
HTTP 応答状態コード - HTTP | MDN
ステータスコードは.status_code
属性に入ります。こんな感じで代入すればOK。
response.status_code = 200
Flaskドキュメント - Responseクラス:API — Flask 0.12.4 documentation
ステータスコードもさっきの関数で追加する
さっきの関数にステータスコードも一緒に渡してしまったほうが便利でしょう。
def prepare_response(data, statuscode): response = flask.make_response(data) response.status_code = statuscode response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains' response.headers['Content-Security-Policy'] = 'default-src \'self\'' response.headers['X-Content-Type-Options'] = 'nosniff' response.headers['X-Frame-Options'] = 'DENY' response.headers['X-XSS-Protection'] = '1; mode=block' return response
変更後のコード
ここまでの内容を反映したコードがこちら。
# coding: UTF-8 import flask from google.appengine.ext import ndb app = flask.Flask(__name__) class User(ndb.Model): name = ndb.StringProperty() mail = ndb.StringProperty() password = ndb.StringProperty() def prepare_response(data, statuscode): response = flask.make_response(data) response.status_code = statuscode response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains' response.headers['Content-Security-Policy'] = 'default-src \'self\'' response.headers['X-Content-Type-Options'] = 'nosniff' response.headers['X-Frame-Options'] = 'DENY' response.headers['X-XSS-Protection'] = '1; mode=block' return response @app.route('/') def toppage(): response_body = flask.render_template('toppage.html') response = prepare_response(response_body, 200) return response @app.route('/showname') def showname(): name = flask.request.args.get('name') response_body = flask.render_template('showname.html', name=name) response = prepare_response(response_body, 200) return response @app.route('/database/new', methods=['POST']) def database_new(): name = flask.request.form['name'] mail = flask.request.form['mail'] password = flask.request.form['password'] user = User(name=name, mail=mail, password=password) user.put() response = prepare_response('', 200) return response @app.route('/database/get', methods=['POST']) def database_get(): name = flask.request.form['name'] password = flask.request.form['password'] query = User.query(ndb.AND(User.name == name, User.password == password)) result = query.get() if result is None: response = prepare_response('ユーザーネームとパスワードの組み合わせが正しくありません' ,401) else: response = prepare_response(result.mail, 200) return response @app.route('/database/reflesh', methods=['POST']) def database_reflesh(): username = flask.request.form['user'] user_entity = User.query(User.name == username).get() #有効なEntityが返ってきた場合はリクエストに含まれるパラメータそれぞれについてPropertyを更新 if user_entity: for key, value in flask.request.form.items(): if key != 'user': if hasattr(user_entity, key): setattr(user_entity, key, value) user_entity.put() response = prepare_response('', 200) else: response = prepare_response('該当するユーザーがみつかりませんでした', 404) return response
基本的にreturn
するのは毎回response
ですね。返すデータ本体がなくてもステータスコードは必ず適切なものを返した方がいいので、基本的にreturn response
はどの場合でも行います。
今回はcookieを利用しませんが、利用する場合はリクエスト強要などへの対策としてSet-Cookieオプションの設定が必要です。
クロスサイトスクリプティング(XSS)対策
クロスサイトスクリプティングとはユーザーの入力を出力に反映する際に起こるものです。
今回その可能性があるのは、
/showname
でのクエリの出力/database/get
でのデータベースの値の出力
の2箇所です。
/shownameの対策
/showname
では以下の点からXSSの心配はありません。(Jinja2の自動エスケープの対象)
- 入力値を直接
flask.render_template()
に渡している - 埋め込み先はタグ内ではない
/database/getの対策
/database/get
ではデータベースの値をflask.render_template()
を使わず直接出力しているので、もしmail
にHTMLタグを渡されればXSSが可能となります。
これには以下3通りの対策が考えられます。
/database/get
での出力にflask.render_template()
を利用する/database/get
で出力前にエスケープする/database/new
および/database/reflesh
でデータベースへの格納前にエスケープする
どれが最適かはケースバイケースです。しかしながらメールアドレスの文字列に<
>
&
;
#
などは不要なはずです。入力の段階でチェックするのがスマートでしょう。
エスケープする
本当は上のような記号が入っている時点でメールアドレスとしておかしいので、その時点でエラーとして再入力を求めたいところです。
が、それをやっていると話が逸れるので今回は単純にflask.escape()
を使います。今回問題となりうるのはmail
だけですが、すべてエスケープしておきましょう。
@app.route('/database/new', methods=['POST']) def database_new(): name = flask.escape(flask.request.form['name']) mail = flask.escape(flask.request.form['mail']) password = flask.escape(flask.request.form['password']) user = User(name=name, mail=mail, password=password) user.put() response = prepare_response('', 200) return response @app.route('/database/get', methods=['POST']) def database_get(): name = flask.escape(flask.request.form['name']) password = flask.escape(flask.request.form['password']) query = User.query(ndb.AND(User.name == name, User.password == password)) result = query.get() if result is None: response = prepare_response('ユーザーネームとパスワードの組み合わせが正しくありません' ,401) else: response = prepare_response(result.mail, 200) return response @app.route('/database/reflesh', methods=['POST']) def database_reflesh(): username = flask.escape(flask.request.form['user']) user_entity = User.query(User.name == username).get() #有効なEntityが返ってきた場合はリクエストに含まれるパラメータそれぞれについてPropertyを更新 if user_entity: for key, value in flask.request.form.items(): if key != 'user': if hasattr(user_entity, key): setattr(user_entity, key, flask.escape(value)) user_entity.put() response = prepare_response('', 200) else: response = prepare_response('該当するユーザーがみつかりませんでした', 404) return response
エスケープして格納しているので、参照の際もエスケープした状態で検索しなければヒットしない点に注意が必要です。
インジェクション対策
flask.request.args
やflask.request.form
から値を取り出す際は.get()
メソッドを利用し、引数で型を指定します。
get(キー, default=値が存在しなかった時の返り値, type=型指定)
これを踏まえて該当部分を書き換えるとこのようになります。if文でdefaultの場合の挙動を設定しておくといいでしょう。
@app.route('/showname') def showname(): name = flask.request.args.get('name', default='名前がありません', type=str) response_body = flask.render_template('showname.html', name=name) response = prepare_response(response_body, 200) return response @app.route('/database/new', methods=['POST']) def database_new(): name = flask.escape(flask.request.form.get('name', default='名前がありません', type=str)) mail = flask.escape(flask.request.form.get('mail', default='メールアドレスがありません', type=str)) password = flask.escape(flask.request.form.get('password', default='パスワードがありません', type=str)) if name == '名前がありません' or mail == 'メールアドレスがありません' or password =='パスワードがありません': response = prepare_response('正しいセットを入力してください', 400) return response user = User(name=name, mail=mail, password=password) user.put() response = prepare_response('', 200) return response @app.route('/database/get', methods=['POST']) def database_get(): name = flask.escape(flask.request.form.get('name', default='名前がありません', type=str)) password = flask.escape(flask.request.form.get('password', default='パスワードがありません', type=str)) query = User.query(ndb.AND(User.name == name, User.password == password)) result = query.get() if result is None: response = prepare_response('ユーザーネームとパスワードの組み合わせが正しくありません', 401) else: response = prepare_response(result.mail, 200) return response @app.route('/database/reflesh', methods=['POST']) def database_reflesh(): username = flask.escape(flask.request.form.get('user', default='名前がありません', type=str)) user_entity = User.query(User.name == username).get() #有効なEntityが返ってきた場合はリクエストに含まれるパラメータそれぞれについてPropertyを更新 if user_entity: #flask.request.form.get()を使うために.items()を.keys()に変更 for key in flask.request.form.keys(): if key != 'user': if hasattr(user_entity, key): #if文の内部なのでdefaultは不要 value = flask.escape(flask.request.form.get(key, type=str)) setattr(user_entity, key, value) user_entity.put() response = prepare_response('', 200) else: response = prepare_response('該当するユーザーがみつかりませんでした', 404) return response
パスワードの扱い
さて、このアプリではまだパスワードをそのままデータベースに保存しています。Google Cloud Datastoreはデフォルトで暗号化された状態で保存されるとはいえ、それは可逆的(復元可能)なものです。(とはいえ復元には暗号化キーが必要ですが。)
そもそもこのままではデータベースの管理者がユーザーのパスワードをみることができます。パスワードは本人以外は知ることができないようになっていなければなりません。
前回やったように、SHA-256で暗号化をしましょう。
手順はこのようになります。
import hashlib password = "123456" #bytes型に変換 password_bytes = password.encode() #ハッシュ化(返り値は'_hashlib.HASH'型) hash_bytes = hashlib.sha256(password_bytes) #str型に変換(16進数の形になります) hashed_password = hash_bytes.hexdigest()
なお、Python2の場合はデフォルトの文字コードがasciiになっているので日本語→bytesの変換でエラーが起こります。これを防ぐためにデフォルトの文字コードをUTF-8にしておきます。(Python3の場合はデフォルトでUTF-8になっているので不要です)
# デフォルトの文字コードをutf-8に(python3ならば不要) import sys reload(sys) sys.setdefaultencoding('utf-8')
パスワードを受け取る全ての場面で暗号化をします。上の手順を毎回やっているのは煩雑なので、関数を作っておきましょう。
import hashlib # デフォルトの文字コードをutf-8に(python3ならば不要) import sys reload(sys) sys.setdefaultencoding('utf-8') def make256(raw): bytes = raw.encode() hash_bytes = hashlib.sha256(bytes) hash_str = hash_bytes.hexdigest() return hash_str @app.route('/database/new', methods=['POST']) def database_new(): name = flask.escape(flask.request.form.get('name', default='名前がありません', type=str)) mail = flask.escape(flask.request.form.get('mail', default='メールアドレスがありません', type=str)) password_raw = flask.escape(flask.request.form.get('password', default='パスワードがありません', type=str)) password_hashed = make256(password_raw) if name == '名前がありません' or mail == 'メールアドレスがありません' or password_raw =='パスワードがありません': response = prepare_response('正しいセットを入力してください', 400) return response user = User(name=name, mail=mail, password=password_hashed) user.put() response = prepare_response('', 200) return response @app.route('/database/get', methods=['POST']) def database_get(): name = flask.escape(flask.request.form.get('name', default='名前がありません', type=str)) password_raw = flask.escape(flask.request.form.get('password', default='パスワードがありません', type=str)) password_hashed = make256(password_raw) query = User.query(ndb.AND(User.name == name, User.password == password_hashed)) result = query.get() if result is None: response = prepare_response('ユーザーネームとパスワードの組み合わせが正しくありません', 401) else: response = prepare_response(result.mail, 200) return response @app.route('/database/reflesh', methods=['POST']) def database_reflesh(): username = flask.escape(flask.request.form.get('user', default='名前がありません', type=str)) user_entity = User.query(User.name == username).get() #有効なEntityが返ってきた場合はリクエストに含まれるパラメータそれぞれについてPropertyを更新 if user_entity: #flask.request.form.get()を使うために.items()を.keys()に変更 for key in flask.request.form.keys(): if key != 'user': if hasattr(user_entity, key): #if文の内部なのでdefaultは不要 value = flask.escape(flask.request.form.get(key, type=str)) #パスワードの場合はハッシュ化 if key == 'password': value = make256(value) setattr(user_entity, key, value) user_entity.put() response = prepare_response('', 200) else: response = prepare_response('該当するユーザーがみつかりませんでした', 404) return response
完成
これで一通りコーディングにおけるセキュリティ対策は完了です。
# coding: UTF-8 import flask from google.appengine.ext import ndb import hashlib # デフォルトの文字コードをutf-8に(python3ならば不要) import sys reload(sys) sys.setdefaultencoding('utf-8') app = flask.Flask(__name__) class User(ndb.Model): name = ndb.StringProperty() mail = ndb.StringProperty() password = ndb.StringProperty() def prepare_response(data, statuscode): response = flask.make_response(data) response.status_code = statuscode response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains' response.headers['Content-Security-Policy'] = 'default-src \'self\'' response.headers['X-Content-Type-Options'] = 'nosniff' response.headers['X-Frame-Options'] = 'DENY' response.headers['X-XSS-Protection'] = '1; mode=block' return response def make256(raw): bytes = raw.encode() hash_bytes = hashlib.sha256(bytes) hash_str = hash_bytes.hexdigest() return hash_str @app.route('/') def toppage(): response_body = flask.render_template('toppage.html') response = prepare_response(response_body, 200) return response @app.route('/showname') def showname(): name = flask.request.args.get('name', default='名前がありません', type=str) response_body = flask.render_template('showname.html', name=name) response = prepare_response(response_body, 200) return response @app.route('/database/new', methods=['POST']) def database_new(): name = flask.escape(flask.request.form.get('name', default='名前がありません', type=str)) mail = flask.escape(flask.request.form.get('mail', default='メールアドレスがありません', type=str)) password_raw = flask.escape(flask.request.form.get('password', default='パスワードがありません', type=str)) password_hashed = make256(password_raw) if name == '名前がありません' or mail == 'メールアドレスがありません' or password_raw =='パスワードがありません': response = prepare_response('正しいセットを入力してください', 400) return response user = User(name=name, mail=mail, password=password_hashed) user.put() response = prepare_response('', 200) return response @app.route('/database/get', methods=['POST']) def database_get(): name = flask.escape(flask.request.form.get('name', default='名前がありません', type=str)) password_raw = flask.escape(flask.request.form.get('password', default='パスワードがありません', type=str)) password_hashed = make256(password_raw) query = User.query(ndb.AND(User.name == name, User.password == password_hashed)) result = query.get() if result is None: response = prepare_response('ユーザーネームとパスワードの組み合わせが正しくありません', 401) else: response = prepare_response(result.mail, 200) return response @app.route('/database/reflesh', methods=['POST']) def database_reflesh(): username = flask.escape(flask.request.form.get('user', default='名前がありません', type=str)) user_entity = User.query(User.name == username).get() #有効なEntityが返ってきた場合はリクエストに含まれるパラメータそれぞれについてPropertyを更新 if user_entity: #flask.request.form.get()を使うために.items()を.keys()に変更 for key in flask.request.form.keys(): if key != 'user': if hasattr(user_entity, key): #if文の内部なのでdefaultは不要 value = flask.escape(flask.request.form.get(key, type=str)) #パスワードの場合はハッシュ化 if key == 'password': value = make256(value) setattr(user_entity, key, value) user_entity.put() response = prepare_response('', 200) else: response = prepare_response('該当するユーザーがみつかりませんでした', 404) return response
おまけ:実際の運用では
ユーザーの追加
今のままでは誰でもユーザーを追加できます。
実際の運用ではまずメールアドレスの有効性を確認→メールアドレスに送信したリンクからのみ登録可
のような形になるかと思います。
例えば可逆的(復元可能)なパラメータを含むリンクを登録をリクエストするメールアドレスに送り、そこからのアクセスの場合に該当のメールアドレスでの登録を許可するなどが考えられます。
これはアイデアというか工夫次第なのでここでは言及しません。
あと、メールアドレスは重複を許さないようにしないといけません。データベースを確認してif entity is None
の場合のみ登録を許可しましょう。
.refleshの挙動
それから当然ながら今回作ったコードのままではユーザー名がわかればPropertyを自由に変更できてしまいます。
ここはユーザー名ではなくメールアドレスとパスワードの組み合わせで確認をすべきでしょう。そもそもユーザー名がかぶることは普通にありえますし
'名前がありません'について
どうでもいですが、今のままだと例えば「名前がありません」というユーザー名では登録できません。どうでもいいですが。