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通りの対策が考えられます。

  1. /database/getでの出力にflask.render_template()を利用する
  2. /database/getで出力前にエスケープする
  3. /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.argsflask.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を自由に変更できてしまいます。

ここはユーザー名ではなくメールアドレスとパスワードの組み合わせで確認をすべきでしょう。そもそもユーザー名がかぶることは普通にありえますし

'名前がありません'について

どうでもいですが、今のままだと例えば「名前がありません」というユーザー名では登録できません。どうでもいいですが。