🚧

FlaskでRateLimit

4 min read 1

Flask-Limiterを使用したアクセス頻度制限の実装方法です。
最初に一般論として、総当たり攻撃対策の話と負荷軽減の話を書いています。


動機:総当たり攻撃対策

Webシステムに対する総当たり攻撃は、ハッカーにとって魅力的な攻撃手法の1つです。
原理的にとても単純であり、対策がなければほぼ確実に攻撃が成功します。手動ではできない攻撃ですが、ごく簡単なプログラムで実現できます。

対策はというと、「知られてはいけないID」をより複雑にして(桁数を増やして)総当たりの試行回数が追いつかないようにすることが第一です。
ここで言う「知られてはいけないID」というのは、通常は「セッションID」と「パスワード」です。他にも、Google Driveでアドレスを知ってる人だけに公開みたいなことをするとURLに長ったらしいIDが含まれる、そういったものです。

アプリケーション側で生成するIDはいくらでも桁数を調整できるので、それで対応可能ですが、ユーザーのパスワードに関しては制御が難しいので、多要素認証の設定を推奨する、とかログイン失敗の回数によるロック機能を設ける、とかいった対策がとられています。ただし、アカウント別のロック機能は逆総当たり(リバースブルートフォース)には無効です。
おそらく、最近の機密性の高いWebシステムのスタンダードはCAPTCHA機能でBOTによるログイン試行を禁止するというものではないかと思います。気の利いた大手のシステムだと、一定時間内に何度もログインしようとした場合のみCAPTCHAが表示されたりして、なるほどの親切設計だと感心します。

今回実施したのは、そもそものログイン試行の頻度を制限するというタイプの対策です。これは単純にゆっくり実行すれば総当たり攻撃が可能ですが、攻撃者の立場で考えるとゆっくりとかやってられないので、効果的だと考えられます。IPで識別するので、同一グローバルIPを利用するユーザーが多い場合に全員が突然遮断されるという問題がありますが、逆総当たりに対しても有効な点は強みです。
今回のFlaskでの実装は特に必要に迫られていたわけではないのですが、ちょっと気になって試してみたというところです。

RateLimitとは

対象のURLに対してHTTPリクエストを送信したときに、HTTPレスポンスヘッダーにX-RateLimit-Limitなどのヘッダーが付与されている場合があります。
これは、対象のWebサービスが同一IPアドレスからの一定時間内のアクセス数を制限していることを意味します。IPアドレス以外のユーザー識別が行われるケースもあるかもしれません。

よくPHPなどのフレームワークで作成されたWebアプリケーションで設定されています。
サイト全体に設定されているものはDoS攻撃対策として採用されているんじゃないかと思います。
無料で利用可能なAPI系のサービスだと、APIの負荷軽減のためにこれを用いてアクセス頻度を制限したりするようです。

X-RateLimit-Limitで指定された回数を超えてリクエストを送信した場合には、403などのレスポンスコードになり、アクセスが拒否されます。
サーバが弱そうなサイトではPHPやJava等のフレームワークがアクセス拒否している場合が多いと思いますが、APサーバが冗長化されているような大規模なサービスの場合は経路上のWAFで制限している可能性もあり、その形は負荷対策として有力だと思います。


Flaskアプリケーションへの設定

前段が長くなりました。
ここからはRateLimitの方式でのアクセス頻度制限をFlaskで実行する方法です。

Flaskは軽量フレームワークなので必要に応じて追加のパッケージをインストールする必要があります。今回の場合は、Flask-Limiterというパッケージをインストールします。

pip install Flask-Limiter

使用方法のチュートリアルは、以下が公式ドキュメントになります。

https://flask-limiter.readthedocs.io/en/stable/

上記ドキュメントを読めばわかりますが、自身が設定した要点のみ記載します。

from flask import Flask
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

app = Flask(__name__)
limiter = Limiter(app, key_func=get_remote_address, default_limits=["100 per minute"])

get_remote_addressはリモートIPアドレスで判別しますよという話ですが、ほかの指定を使うケースはほぼ無いんじゃないかと思います。(独自に定義を作れば国別で判定とかできるかも?)
デフォルトの制限値を100 per minuteに設定しているので、全routeに対して「1分あたり100回まで」という制限が付与されることになります。

route別に追加設定したりデフォルト除外したい場合は以下のようになるそうです。

@app.route("/login")
@limiter.limit("500 per hour") # 制限値を追加
def login():
    ### ログイン処理
    
@app.route("/lp")
@limiter.exempt # 制限の対象外とする
def lp():
    ### ランディングページ

@app.route("/manage")
@limiter.limit("100 per day", exempt_when=lambda: current_user.is_admin) # 管理者の制限を免除
def manage():
    ### 管理上必要な処理

Nginxでの設定

[2/28 追記]
Nginxの設定について、勘違いがあったようなので削除しました。
リバースプロキシの場合はリモートアドレスを正しくFlaskへ伝達する必要があると思いますが、NginxからuwsgiでFlaskへ連携している状態で特に設定は不要なようでした。

以下の画像はテスト用に10 per hourで実施したときのブラウザ表示です。

レスポンスヘッダーへの情報付与

さて、上記でアクセス制限が効くようになったのですが、ブラウザでアクセスした際のレスポンスヘッダーを確認すると、X-RateLimit-Limitはありませんでした。
勝手につけてくれるものだと思ってたのですが、どうやらデフォルトではレスポンスヘッダーに情報を載せないようになっています。

特に必要性はないと思いますが、なんとなく気持ち悪いので情報を出力する設定を入れておきました。

app = Flask(__name__)
app.config['RATELIMIT_HEADERS_ENABLED'] = True # ヘッダーにRateLimit情報を出力
limiter = Limiter(app, key_func=get_remote_address, default_limits=["100 per minute"])

これで、当初想定していた動作になりました。
レスポンスにはX-RateLimitの3項目が出力されています。

回数の情報はどこに保存されるのか

やりたいことは実現できましたが、気になることがあります。
システムがIPアドレスごとのアクセス回数を記憶しておく必要があるはずですが、この情報がどこに保存されているのかということです。

RATELIMIT_STORAGE_URLなどのオプションの説明を見ると、デフォルトではメモリ上に管理されているようです。(limits.storage.MemoryStorage
このあたり詳しい仕様は知りませんが、ちゃんとしたプロダクションで使用する場合はMemcachedやRedis等を連携させろということが書かれています。

[2/28 追記]
メモリで管理している状態だと、プロセスが複数あると統合管理ができないものと思われます。
X-RateLimit-Remainingの数値の減り方がおかしかったのですが、uwsgiのworkersの設定を1にすると正常になりました。

納得して、すっきりしました。
本番利用もできそうです。

Discussion

Nginxの設定について勘違いがあったようなので削除しました。

ログインするとコメントできます