【GROWI】可変的なAPI rate limitを実装する

はじめに

こんにちは、インターンの手塚です。今回は、GROWIに新たに実装されるAPIの制限について書いていきたいと思います。APIの制限とは、機械的にたくさんのリクエストが一気に送られるのを防ぐ機能です。そのために特定のエンドポイントに対して、一定時間内に一定回数以上のリクエストが送られたときに429 too many requestのエラーを返します。

動機

const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 10, // limit each IP to 10 requests per windowMs
  message:
    'Too many requests sent from this IP, please try again after 15 minutes',
});
~~ 省略~~
app.post('/login' , apiLimiter , hoge);

GROWIにはもともと、上のようにAPIの制限が実装されていましたが、閾値がハードコードされているので、OSSとしてGROWIを扱う人が簡単に制限を変更できるものではありませんでした。しかしユーザーさんから、「自分たちで環境変数などでAPIに制限をかけたい」という要望があったことで、今回の新しく可変的なAPI制限の機能を実装するに至りました。

設計

GROWIでは、もともとAPIの制限を実装するのにexpress-rate-limitというライブラリを使っていましたが、このライブラリでは柔軟にエンドポイントごとに設定を変えることができなかったため、新たにrate-limiter-flexibleというライブラリを導入しています。

express-rate-limitドキュメント

rate-limiter-flexibleドキュメント

const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15分
  max: 10, // 最大10リクエスト
  message:
    'Too many requests sent from this IP, please try again after 15 minutes',
});
~~ 省略~~
app.post('/login' , apiLimiter , hoge);

express-rate-limitでは上のように、一つのrateLimitインスタンスがもつ制限の内容は変更できず、複数種類の制限をかけるには、複数のインスタンスを作成する必要がありました。

const opts = {
  points: 100, // 最大ポイント数
  duration: 15 * 60 * 1000, // 15分
};

const rateLimiter = new RateLimiter(opts);

rateLimiter.consume(key, 10); // 1リクエストで10ポイント消費

それに対し、rate-limiter-flexibleでは、最大ポイントから、英クエストごとに一定のポイントを消費し、ポイントがなくなったらtoo many requestsという少し特殊な制限方法にすることで、一つのインスタンスに対して、keyと消費するポイントを設定することで制限を柔軟に変更することができます。これが、rate-limiter-flexibleを採用した理由です。

keyについて

rate-limiter-flexibleでは、keyと呼ばれる文字列に対して一定時間の制限を設け、apiに制限をかけるという仕組みになっています。GROWIでは、ログインユーザーに対しては、ユーザーID+エンドポイント+リクエストメソッドというkeyを設定しています。こうすることで、同一IPアドレスから複数のログインユーザーのリクエストがあってもそれぞれのユーザーのリクエストに対して制限をかけることができます。ログインしていないゲストユーザーに対しては、IPアドレス+エンドポイント+リクエストメソッドというkeyを設定しています。

rate limitの設定方法

ユーザー目線での設定のしやすさから、環境変数を用いて、サーバー起動時に環境変数から設定を取得し、APIの制限を実装するようにします。

API_RATE_LIMIT_010_LOGIN_ENDPOINT=/login
API_RATE_LIMIT_010_LOGIN_METHODS=GET
API_RATE_LIMIT_010_LOGIN_MAX_REQUESTS=20

ユーザーが設定可能な項目は、エンドポイントメソッド1秒あたりの最大リクエスト数で、環境変数を上のように設定することで、サーバー起動時に設定を取得できるようにし、同一エンドポイントに複数の設定がある場合はkeyを昇順でソートして、後ろに来る設定を優先するようにします。keyは上の例での、010_LOGINの部分です。

また、/share/:pageIdのように可変のURLに対応するため、

API_RATE_LIMIT_010_SHARE_ENDPOINT_WITH_REGEXP=/share/[0-9a-z]{24}
API_RATE_LIMIT_010_SHARE_METHODS=GET
API_RATE_LIMIT_010_SHARE_MAX_REQUESTS=20

このように、エンドポイント部分に正規表現を用いて設定ができるようにもしました。

rate limitの初期設定

export const DEFAULT_MAX_REQUESTS = 500;
export const DEFAULT_DURATION_SEC = 60;
export const DEFAULT_USERS_PER_IP_PROSPECTION = 5;

ユーザーが全てのエンドポイントに対して制限を設定できるように全てのエンドポイントに制限を設定していますが、カスタマイズされていないエンドポイントには上の設定が適用され、60秒間に500回が最大リクエストとなります。この値は、ユーザーのカスタマイズされた値によって上書きされます。

const MAX_REQUESTS_TIER_1 = 5;
const MAX_REQUESTS_TIER_2 = 20;
const MAX_REQUESTS_TIER_3 = 50;
const MAX_REQUESTS_TIER_4 = 100;

上のデフォルト制限の他に、GROWIでは上のような独自に作成したTierの概念を用いて、POST /loginのような、あらかじめ少し厳しい制限が必要と思われるエンドポイントには初期値を設定しています。この値も、ユーザーのカスタマイズされた値によって上書きされます。

未ログインユーザーに対しては、デフォルトでは1IPアドレスあたり5人という想定で、許容するアクセス数を5倍にしています。この値も、DEFAULT_USERS_PER_IP_PROSPECTIONという環境変数でエンドポイントごとに設定することができ、このような仕組みにすることで、ログイン済みユーザーもゲストユーザーにも対応できるrate limitを実装しています。

実装

const apiRateLimiter = require('../middlewares/api-rate-limiter')();
~~省略~~

  app.use(apiRateLimiter); // 全てのエンドポイントに制限をかける

~~省略~~

ユーザーがすべてのエンドポイントにAPI制限をかけることができるようにするため、すべてのエンドポイントに対してデフォルトで制限をかけます。


~~省略~~
const rateLimiter = new RateLimiterMongo(opts);

// 環境変数から値を取得してくる
~~省略~~

// ポイントを消費して制限をかけるミドルウェア(本体)

module.exports = () => {

  return async(req: Request & { user?: IUserHasId }, res: Response, next: NextFunction) => {

    const endpoint = req.path;

    // ログインしているか、していないかでkeyを作成する
    const keyForUser: string | null = req.user != null
      ? md5(`${req.user._id}_${endpoint}_${req.method}`)
      : null;
    const keyForIp: string = md5(`${req.ip}_${endpoint}_${req.method}`);

    //  リクエストされたエンドポイントに対して、設定があるかを確認
    let customizedConfig: IApiRateLimitConfig | undefined;
    const configForEndpoint = configWithoutRegExp[endpoint];
    if (configForEndpoint) {
      customizedConfig = configForEndpoint;
    }
    else if (allRegExp.test(endpoint)) {
      keysWithRegExp.forEach((key, index) => {
        if (key.test(endpoint)) {
          customizedConfig = valuesWithRegExp[index];
        }
      });
    }

    // check for the current user
    if (req.user != null) {
      try {
        await consumePointsByUser(req.method, keyForUser, customizedConfig);
      }
      catch {
        logger.error(`${req.user._id}: too many request at ${endpoint}`);
        return res.sendStatus(429);
      }
    }

    // check for ip
    try {
      await consumePointsByIp(req.method, keyForIp, customizedConfig);
    }
    catch {
      logger.error(`${req.ip}: too many request at ${endpoint}`);
      return res.sendStatus(429);
    }

    return next();
  };
};

リクエストが来てから、このミドルウェアが適用される流れは以下の通りです。

  1. デフォルトの設定(初期設定)を読み込む
  2. 環境変数から設定(ユーザーカスタマイズ設定)を読み込む
  3. 二つの設定をマージしAPI制限の設定としてサーバーが保持
  4. リクエストが来るたびに設定をチェックし、設定があれば適用

この順序で処理を行うことで、環境変数を用いた可変的なAPIの制限を実現しています。

最後に

このrate limitの新機能はGROWI v5.1.0でリリースされました。良かったらぜひ、使ってみてください!

以上、GROWIの新しいAPI制限についてでした。