AppPlatformでNode.jsを使用してレートリミッターを構築する方法

提供:Dev Guides
移動先:案内検索

著者はCOVID-19救済基金を選択し、 Write forDOnationsプログラムの一環として寄付を受け取りました。

序章

レート制限は、ネットワークのトラフィックを管理し、APIの使用など、特定の期間に誰かが操作を繰り返す回数を制限します。 レート制限の乱用に対するセキュリティの層がないサービスは、過負荷になりがちであり、正当な顧客に対するアプリケーションの適切な動作を妨げます。

このチュートリアルでは、リクエストのIPアドレスを確認し、ユーザーごとのリクエストのタイムスタンプを比較してこれらのリクエストの割合を計算するNode.jsサーバーを構築します。 IPアドレスがアプリケーションに設定した制限を超える場合は、CloudflareのAPIを呼び出し、IPアドレスをリストに追加します。 次に、リストにIPアドレスを持つすべてのリクエストを禁止するCloudflareファイアウォールルールを構成します。

このチュートリアルの終わりまでに、DigitalOceanの App Platform にデプロイされたNode.jsプロジェクトを構築し、レート制限でCloudflareルーティングドメインを保護します。

前提条件

このガイドを開始する前に、次のものが必要です。

ステップ1— Node.jsプロジェクトをセットアップし、DigitalOceanのアプリプラットフォームにデプロイする

このステップでは、基本的なExpressサーバーを拡張し、コードをGitHubリポジトリにプッシュして、アプリケーションをAppPlatformにデプロイします。

コードエディタを使用して、基本的なExpressサーバーのプロジェクトディレクトリを開きます。 プロジェクトのルートディレクトリに.gitignoreという名前で新しいファイルを作成します。 新しく作成した.gitignoreファイルに次の行を追加します。

.gitignore

node_modules/
.env

.gitignoreファイルの最初の行は、node_modulesディレクトリを追跡しないようにgitに指示するものです。 これにより、リポジトリのサイズを小さく保つことができます。 node_modulesは、コマンドnpm installを実行することにより、必要に応じて生成できます。 2行目は、環境変数ファイルが追跡されないようにします。 次の手順で.envファイルを作成します。

コードエディタでserver.jsに移動し、次のコード行を変更します。

server.js

...
app.listen(process.env.PORT || 3000, () => {
    console.log(`Example app is listening on port ${process.env.PORT || 3000}`);
});

PORTを環境変数として条件付きで使用するように変更すると、アプリケーションは、割り当てられたPORTでサーバーを動的に実行したり、フォールバックとして3000を使用したりできます。

注: console.log()の文字列は、引用符ではなく、backticks( `)で囲まれています。 これにより、テンプレートリテラルを使用できるようになります。これにより、文字列内に式を含めることができます。


ターミナルウィンドウにアクセスして、アプリケーションを実行します。

node server.js

ブラウザウィンドウにSuccessful responseが表示されます。 ターミナルに、次の出力が表示されます。

OutputExample app is listening on port 3000

Expressサーバーが正常に実行されたら、AppPlatformにデプロイします。

まず、プロジェクトのルートディレクトリでgitを初期化し、コードをGitHubアカウントにプッシュします。 ブラウザでAppPlatformダッシュボードに移動し、 CreateAppボタンをクリックします。 GitHub オプションを選択し、必要に応じてGitHubで承認します。 AppPlatformにデプロイするプロジェクトのドロップダウンリストからプロジェクトのリポジトリを選択します。 構成を確認してから、アプリケーションに名前を付けます。 このチュートリアルでは、アプリケーションの開発フェーズで作業するため、Basicプランを選択します。 準備ができたら、アプリの起動をクリックします。

次に、設定タブに移動し、ドメインセクションをクリックします。 Cloudflare経由でルーティングされたドメインをドメインまたはサブドメイン名フィールドに追加します。 箇条書きを選択します。ドメインを管理して、ドメインのCloudflareDNSアカウントに追加するために使用するCNAMEレコードをコピーします。

アプリケーションをAppPlatformにデプロイしたら、後でApp Platformのダッシュボードに戻るので、新しいタブでCloudflareのドメインのダッシュボードに移動します。 DNSタブに移動します。 レコードの追加ボタンをクリックし、タイプとして CNAME 、ルートとして @ を選択し、[に貼り付けますX134X]AppPlatformからコピーしました。 保存ボタンをクリックし、AppPlatformのダッシュボードの設定タブの下にあるドメインセクションに移動し、ドメインの追加をクリックします。 ] ボタン。

展開タブをクリックして、展開の詳細を確認します。 展開が完了したら、your_domainを開いてブラウザで表示できます。 ブラウザウィンドウにSuccessful responseと表示されます。 AppPlatformダッシュボードのRuntimeLogs タブに移動すると、次の出力が表示されます。

OutputExample app is listening on port 8080

注:ポート番号8080は、AppPlatformによってデフォルトで割り当てられたポートです。 展開前にアプリを確認しながら構成を変更することで、これをオーバーライドできます。


アプリケーションがAppPlatformにデプロイされたので、レートリミッターへのリクエストを計算するためにキャッシュの概要を説明する方法を見てみましょう。

ステップ2—ユーザーのIPアドレスをキャッシュして1秒あたりのリクエスト数を計算する

このステップでは、ユーザーのIPアドレスをタイムスタンプの配列とともに cache に保存し、各ユーザーのIPアドレスの1秒あたりのリクエスト数を監視します。 キャッシュは、アプリケーションで頻繁に使用されるデータの一時的なストレージです。 キャッシュ内のデータは通常、RAM(ランダムアクセスメモリ)などのクイックアクセスハードウェアに保持されます。 キャッシュの基本的な目標は、キャッシュの下にある低速のストレージレイヤーにアクセスする必要性を減らすことで、データ取得のパフォーマンスを向上させることです。 プロセスを支援するために、node-cacheis-ip、およびrequest-ipの3つのnpmパッケージを使用します。

request-ipパッケージは、サーバーの要求に使用されるユーザーのIPアドレスをキャプチャします。 node-cacheパッケージは、ユーザーの要求を追跡するために使用するメモリ内キャッシュを作成します。 is-ipパッケージを使用して、IPアドレスがIPv6アドレスであるかどうかを確認します。 node-cacheis-ip、およびrequest-ipパッケージをnpm経由で端末にインストールします。

npm i node-cache is-ip request-ip

コードエディタでserver.jsファイルを開き、const express = require('express');の下に次のコード行を追加します。

server.js

...
const requestIP = require('request-ip');
const nodeCache = require('node-cache');
const isIp = require('is-ip');
...

ここの最初の行は、インストールしたrequest-ipパッケージからrequestIPモジュールを取得します。 このモジュールは、サーバーの要求に使用されるユーザーのIPアドレスをキャプチャします。 2行目は、node-cacheパッケージからnodeCacheモジュールを取得します。 nodeCacheはメモリ内キャッシュを作成します。これを使用して、1秒あたりのユーザーのリクエストを追跡します。 3行目は、is-ipパッケージからisIpモジュールを取得します。 これは、IPアドレスがIPv6であるかどうかをチェックします。これは、Cloudflareの仕様に従ってCIDR表記を使用するようにフォーマットします。

server.jsファイルで定数変数のセットを定義します。 これらの定数は、アプリケーション全体で使用します。

server.js

...
const TIME_FRAME_IN_S = 10;
const TIME_FRAME_IN_MS = TIME_FRAME_IN_S * 1000;
const MS_TO_S = 1 / 1000;
const RPS_LIMIT = 2;
...

TIME_FRAME_IN_Sは、アプリケーションがユーザーのタイムスタンプを平均化する期間を決定する定数変数です。 期間を長くすると、キャッシュサイズが大きくなるため、より多くのメモリを消費します。 TIME_FRAME_IN_MS定数変数は、アプリケーションがユーザーのタイムスタンプを平均化する期間も決定しますが、ミリ秒単位です。 MS_TO_Sは、ミリ秒単位の時間を秒単位に変換するために使用する変換係数です。 RPS_LIMIT変数は、レートリミッターをトリガーし、アプリケーションの要件に従って値を変更するアプリケーションのしきい値制限です。 RPS_LIMIT変数の値2は、開発フェーズ中にトリガーされる中程度の値です。

Expressを使用すると、サーバーに送信されるすべてのHTTPリクエストにアクセスできるミドルウェア関数を記述して使用できます。 ミドルウェア関数を定義するには、app.use()を呼び出して関数を渡します。 ミドルウェアとしてipMiddlewareという名前の関数を作成します。

server.js

...
const ipMiddleware = async function (req, res, next) {
    let clientIP = requestIP.getClientIp(req);
    if (isIp.v6(clientIP)) {
        clientIP = clientIP.split(':').splice(0, 4).join(':') + '::/64';
    }
    next();
};
app.use(ipMiddleware);

...

requestIPが提供するgetClientIp()関数は、ミドルウェアからの要求オブジェクトreqをパラメーターとして受け取ります。 .v6()関数はis-ipモジュールから取得され、渡された引数がIPv6アドレスの場合はtrueを返します。 Cloudflareのリストには、/64CIDR表記のIPv6アドレスが必要です。 aaaa:bbbb:cccc:dddd::/64の形式に従うように、IPv6アドレスをフォーマットする必要があります。 .split(':')メソッドは、IPアドレスを含む文字列から配列を作成し、文字:で分割します。 .splice(0,4)メソッドは、配列の最初の4つの要素を返します。 .join(':')メソッドは、文字:と組み合わせた配列から文字列を返します。

next()呼び出しは、ミドルウェアが存在する場合、次のミドルウェア関数に移動するようにミドルウェアに指示します。 あなたの例では、GETルート/へのリクエストを受け取ります。 これは、関数の最後に含めることが重要です。 そうしないと、リクエストはミドルウェアから転送されません。

定数の下に次の変数を追加して、node-cacheのインスタンスを初期化します。

server.js

...
const IPCache = new nodeCache({ stdTTL: TIME_FRAME_IN_S, deleteOnExpire: false, checkperiod: TIME_FRAME_IN_S });
...

定数変数IPCacheを使用すると、nodeCacheにネイティブなデフォルトのパラメーターをカスタムプロパティでオーバーライドします。

  • stdTTL:キャッシュ要素のキーと値のペアがキャッシュから削除されるまでの秒単位の間隔。 TTLは、 Time To Live の略で、キャッシュが期限切れになるまでの時間の尺度です。
  • deleteOnExpireexpiredイベントを処理するカスタムコールバック関数を作成するため、falseに設定します。
  • checkperiod:期限切れの要素の自動チェックがトリガーされるまでの秒単位の間隔。 デフォルト値は600であり、アプリケーションの要素の有効期限が小さい値に設定されているため、有効期限のチェックもより早く行われます。

node-cacheのデフォルトパラメータの詳細については、node-cachenpmパッケージのドキュメントページが役立ちます。 次の図は、キャッシュがデータを格納する方法を視覚化するのに役立ちます。

ここで、新しいIPアドレスの新しいキーと値のペアを作成し、IPアドレスがキャッシュに存在する場合は、既存のキーと値のペアに追加します。 値は、アプリケーションに対して行われた各要求に対応するタイムスタンプの配列です。 server.jsファイルで、IPCache定数変数の下にupdateCache()関数を作成して、リクエストのタイムスタンプをキャッシュに追加します。

server.js

...
const updateCache = (ip) => {
    let IPArray = IPCache.get(ip) || [];
    IPArray.push(new Date());
    IPCache.set(ip, IPArray, (IPCache.getTtl(ip) - Date.now()) * MS_TO_S || TIME_FRAME_IN_S);
};
...

関数の最初の行は、指定されたIPアドレスのタイムスタンプの配列を取得します。nullの場合は、空の配列で初期化します。 次の行では、new Date()関数によってキャッチされた現在のタイムスタンプを配列にプッシュしています。 node-cacheによって提供される.set()関数は、keyvalue、およびTTLの3つの引数を取ります。 このTTLは、IPCache変数からstdTTLの値を置き換えることにより、標準のTTLセットをオーバーライドします。 IPアドレスがすでにキャッシュに存在する場合は、既存のTTLを使用します。 それ以外の場合は、TTLをTIME_FRAME_IN_Sに設定します。

現在のキーと値のペアのTTLは、有効期限のタイムスタンプから現在のタイムスタンプを差し引くことによって計算されます。 次に、差が秒に変換され、.set()関数の3番目の引数として渡されます。 。getTtl()関数は、引数としてキーとIPアドレスを受け取り、キーと値のペアのTTLをタイムスタンプとして返します。 IPアドレスがキャッシュに存在しない場合、undefinedを返し、TIME_FRAME_IN_Sのフォールバック値を使用します。

注: JavaScriptはミリ秒単位で保存するのに対し、node-cacheモジュールは秒を使用するため、ミリ秒から秒への変換タイムスタンプが必要です。


ipMiddlewareミドルウェアで、ifコードブロックif (isIp.v6(clientIP))の後に次の行を追加して、アプリケーションを呼び出すIPアドレスの1秒あたりのリクエスト数を計算します。

server.js

...
    updateCache(clientIP);
    const IPArray = IPCache.get(clientIP);
    if (IPArray.length > 1) {
        const rps = IPArray.length / ((IPArray[IPArray.length - 1] - IPArray[0]) * MS_TO_S);
        if (rps > RPS_LIMIT) {
            console.log('You are hitting limit', clientIP);
        }
    }
...

最初の行は、宣言したupdateCache()関数を呼び出して、IPアドレスによって行われた要求のタイムスタンプをキャッシュに追加します。 2行目は、IPアドレスのタイムスタンプの配列を収集します。 タイムスタンプの配列内の要素の数が1より大きく(1秒あたりのリクエストの計算には最低2つのタイムスタンプが必要)、1秒あたりのリクエストが定数で定義したしきい値を超える場合、[ X247X]IPアドレス。 rps変数は、リクエスト数を時間間隔の差で割って1秒あたりのリクエスト数を計算し、単位を秒に変換します。

プロパティdeleteOnExpireIPCache変数の値falseにデフォルト設定したため、expiredイベントを手動で処理する必要があります。 node-cacheは、expiredイベントでトリガーするコールバック関数を提供します。 IPCache定数変数の下に次のコード行を追加します。

server.js

...
IPCache.on('expired', (key, value) => {
    if (new Date() - value[value.length - 1] > TIME_FRAME_IN_MS) {
        IPCache.del(key);
    }
});
...

.on()は、期限切れの要素のkeyvalueを引数として受け入れるコールバック関数です。 キャッシュ内のvalueは、リクエストのタイムスタンプの配列です。 強調表示された行は、配列の最後の要素が現在よりも過去に少なくともTIME_FRAME_IN_Sであるかどうかを確認します。 タイムスタンプの配列に要素を追加しているときに、valueの最後の要素が現在よりも過去に少なくともTIME_FRAME_IN_Sである場合、.del()関数はkeyを引数として使用し、期限切れの要素をキャッシュから削除します。

配列の一部の要素が現在よりも過去に少なくともTIME_FRAME_IN_Sである場合は、キャッシュから期限切れのアイテムを削除して処理する必要があります。 ifコードブロックif (new Date() - value[value.length - 1] > TIME_FRAME_IN_MS)の後に、コールバック関数に次のコードを追加します。

server.js

...
    else {
        const updatedValue = value.filter(function (element) {
            return new Date() - element < TIME_FRAME_IN_MS;
        });
        IPCache.set(key, updatedValue, TIME_FRAME_IN_S - (new Date() - updatedValue[0]) * MS_TO_S);
    }
...

JavaScriptにネイティブなfilter()配列メソッドは、タイムスタンプの配列内の要素をフィルター処理するためのコールバック関数を提供します。 あなたの場合、強調表示された行は、過去に現在よりもTIME_FRAME_IN_Sが最も少ない要素をチェックします。 フィルタリングされた要素は、updatedValue変数に追加されます。 これにより、updatedValue変数のフィルター処理された要素と新しいTTLでキャッシュが更新されます。 updatedValue変数の最初の要素に一致するTTLは、キャッシュが次の要素を削除すると、.on('expired')コールバック関数をトリガーします。 TIME_FRAME_IN_Sと、updatedValueの最初のリクエストのタイムスタンプからの有効期限の差により、新しく更新されたTTLが計算されます。

ミドルウェア機能が定義されたら、ターミナルウィンドウにアクセスして、アプリケーションを実行します。

node server.js

次に、Webブラウザでlocalhost:3000にアクセスします。 ブラウザウィンドウにSuccessful responseと表示されます。 ページを繰り返し更新して、RPS_LIMITを押します。 ターミナルウィンドウに次のように表示されます。

OutputExample app is listening on port 3000
You are hitting limit ::1

注:ローカルホストのIPアドレスは::1と表示されます。 アプリケーションは、ローカルホストの外部にデプロイされたときにユーザーのパブリックIPをキャプチャします。


これで、アプリケーションはユーザーの要求を追跡し、タイムスタンプをキャッシュに保存できるようになります。 次のステップでは、CloudflareのAPIを統合してファイアウォールを設定します。

ステップ3—Cloudflareファイアウォールを設定する

このステップでは、レート制限に達したときにIPアドレスをブロックし、環境変数を作成し、CloudflareAPIを呼び出すようにCloudflareのファイアウォールを設定します。

ブラウザでCloudflareダッシュボードにアクセスし、ログインして、アカウントのホームページに移動します。 構成タブでリストを開きます。 your_listという名前で新しいリストを作成します。

注: リストセクションは、Cloudflareドメインのダッシュボードページではなく、Cloudflareアカウントのダッシュボードページで利用できます。


ホームタブに移動し、your_domain'のダッシュボードを開きます。 ファイアウォールタブを開き、ファイアウォールルールセクションの下にあるファイアウォールルールの作成をクリックします。 your_rule_nameをファイアウォールに渡して識別します。 フィールドで、ドロップダウンからIP Source Addressを選択し、オペレーターis in listを選択し、値にyour_listを選択します。アクションの選択のドロップダウンで、ブロックを選択し、展開をクリックします。

プロジェクトのルートディレクトリに.envファイルを作成し、次の行を使用して、アプリケーションからCloudflareAPIを呼び出します。

.env

ACCOUNT_MAIL=your_cloudflare_login_mail
API_KEY=your_api_key
ACCOUNT_ID=your_account_id
LIST_ID=your_list_id

API_KEYの値を取得するには、CloudflareダッシュボードのマイプロファイルセクションのAPIトークンタブに移動します。 [グローバルAPIキー]セクションの表示をクリックし、Cloudflareパスワードを入力して表示します。 アカウントのホームページの構成タブの下にあるリストセクションにアクセスします。 作成したyour_listリストの横にある編集をクリックします。 ブラウザのyour_listのURLからACCOUNT_IDLIST_IDを取得します。 URLは次の形式です:https://dash.cloudflare.com/your_account_id/configurations/lists/your_list_id

警告: .envの内容は機密扱いであり、公開されていないことを確認してください。 手順1で作成した.gitignoreファイルに.envファイルがリストされていることを確認してください。 <$>

axiosおよびdotenvパッケージをnpm経由で端末にインストールします。

npm i axios dotenv

コードエディタでserver.jsファイルを開き、nodeCache定数変数の下に次のコード行を追加します。

server.js

...
const axios = require('axios');
require('dotenv').config();
...

ここの最初の行は、インストールしたaxiosパッケージからaxiosモジュールを取得します。 このモジュールを使用して、CloudflareのAPIへのネットワーク呼び出しを行います。 2行目は、dotenvモジュールを必要とし、.envファイルに配置した値をserver.jsに定義するprocess.envグローバル変数を有効にするように構成します。

console.log('You are hitting limit', clientIP)の上のipMiddleware内のif (rps > RPS_LIMIT)条件に以下を追加して、CloudflareAPIを呼び出します。

server.js

...
    const url = `https://api.cloudflare.com/client/v4/accounts/${process.env.ACCOUNT_ID}/rules/lists/${process.env.LIST_ID}/items`;
    const body = [{ ip: clientIP, comment: 'your_comment' }];
    const headers = {
        'X-Auth-Email': process.env.ACCOUNT_MAIL,
        'X-Auth-Key': process.env.API_KEY,
        'Content-Type': 'application/json',
    };
    try {
        await axios.post(url, body, { headers });
    } catch (error) {
        console.log(error);
    }
...

これで、URLを介してCloudflare APIを呼び出し、アイテム(この場合はIPアドレス)をyour_listに追加します。 Cloudflare APIは、X-Auth-EmailおよびX-Auth-Keyとしてキーを使用して、リクエストのヘッダーにあるACCOUNT_MAILおよびAPI_KEYを取得します。 リクエストの本文は、リストに追加するIPアドレスとしてipを使用し、エントリを識別するために値your_commentを使用するcommentを持つオブジェクトの配列を取ります。 commentの値は、独自のカスタムコメントで変更できます。 axios.post()を介して行われたPOST要求は、発生する可能性のあるエラーがある場合はそれを処理するために、try-catchブロックでラップされます。 axios.post関数は、urlbody、およびheadersを持つオブジェクトを取得して要求を行います。

Cloudflareはリスト内のローカルホストのIPアドレスを受け入れないため、198.51.100.0/24などのテストIPアドレスを使用してAPIリクエストをテストする場合は、ipMiddleware関数内のclientIP変数を変更してください。

server.js

...
let clientIP = '198.51.100.0/24';
...

ターミナルウィンドウにアクセスして、アプリケーションを実行します。

node server.js

次に、Webブラウザでlocalhost:3000にアクセスします。 ブラウザウィンドウにSuccessful responseと表示されます。 ページを繰り返し更新して、RPS_LIMITを押します。 ターミナルウィンドウに次のように表示されます。

OutputExample app is listening on port 3000
You are hitting limit ::1

制限に達したら、Cloudflareダッシュボードを開き、your_list'のページに移動します。 your_listという名前のCloudflareのリストに追加されたコードに入力したIPアドレスが表示されます。 変更をGitHubにプッシュすると、ファイアウォールページが表示されます。

<$>[警告] 警告: 必ず値を変更してくださいclientIPに可変requestIP.getClientIp(req)コードをGitHubにデプロイまたはプッシュする前。


変更をコミットし、コードをGitHubにプッシュして、アプリケーションをデプロイします。 自動デプロイを設定すると、GitHubのコードがDigitalOceanのAppPlatformに自動的にデプロイされます。 .envファイルはGitHubに追加されていないため、アプリレベルの環境変数セクションの設定タブからAppPlatformに追加する必要があります。 プロジェクトの.envファイルからキーと値のペアを追加して、アプリケーションがAppPlatform上のコンテンツにアクセスできるようにします。 環境変数を保存した後、展開が完了したらブラウザでyour_domainを開き、ページを繰り返し更新してRPS_LIMITを押します。 制限に達すると、ブラウザにCloudflareのファイアウォールページが表示されます。

AppPlatformダッシュボードのRuntimeLogs タブに移動すると、次の出力が表示されます。

Output...
You are hitting limit your_public_ip

別のデバイスから、またはVPN経由でyour_domainを開くと、ファイアウォールがyour_listのIPアドレスのみを禁止していることを確認できます。 Cloudflareダッシュボードを介してyour_listからIPアドレスを削除できます。

注:ブラウザからの応答がキャッシュされているため、ファイアウォールがトリガーされるまでに数秒かかる場合があります。


Cloudflare APIを呼び出して、ユーザーがレート制限に達したときにIPアドレスをブロックするようにCloudflareのファイアウォールを設定しました。

結論

この記事では、Cloudflareを介してルーティングされたドメインに接続されたDigitalOceanのAppPlatformにデプロイされたNode.jsプロジェクトを構築しました。 Cloudflareでファイアウォールルールを設定することにより、レート制限の誤用からドメインを保護しました。 ここから、ユーザーを禁止する代わりに、ファイアウォールルールを変更してJSチャレンジまたはCAPTCHAを表示できます。 Cloudflareのドキュメントにプロセスの詳細が記載されています。