0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【その3: Apache と phpプログラムで、アクセス可否を判定できるようにする (仮)】webページへのアクセス頻度をISP単位で制限してみる【Apache + PHP + SQLite3】

Posted at

目次:

前回:

次回:
(執筆中)


1. 今回の目標

特定の IPアドレスだけアクセス拒否するのを php プログラムで実現する。
ここでいう「アクセス拒否」とは、
/BBASN_429.php?reason=(理由) に内部リダイレクトし、このファイルが

  • ステータス 429 Too Many Requests を返すこと
  • ヘッダ Retry-After を設定すること

である。

2. 設計

2-1. 最初に結論

  • プログラム ipcheck で標準出力の内容を制御することで、アクセス可否を判定できる。

  • リクエストごとに プログラム ipcheck から標準出力を受け取り、
    その内容が "BLOCK:(理由)" 形式の場合に /BBASN_429.php?reason=(理由) に飛ばすには

    RewriteCond ${ipcheck:%{REMOTE_ADDR}} ^BLOCK:(.+)$
    RewriteRule ^ /BBASN_429.php?reason=%1 [END,E=BBASN_BLOCKED:1]
    

    のようにすればよい。
     

  • /BBASN_429.php が ステータス 429 Too Many Requests を返すようにするには、単に
    http_response_code(429); をコード先頭に入れればよい。

  • /BBASN_429.php が ヘッダ Retry-After を返すようにするには、単に
    header("Retry-After: (内容)"); をコード先頭に入れればよい。

2-2. リクエストごとに php から標準出力を受け取り、パターンマッチングする方法

RewriteCond ${(プログラム名):(標準入力)} (パターン)
RewriteRule ^ (Substitution) (flags)

とすると、リクエストごとにプログラムに標準入力を渡し、その結果返ってくる標準出力を正規表現でパターンマッチングできる。…①
さらに、 Substitution では %N (N=0...9) によって、 (パターン) に対する後方参照(=正規表現のキャプチャ)を利用できる。…②

①②について、公式ドキュメントの説明

① のパターンマッチングについては、公式ドキュメント1 の 「RewriteCond Directive」の見出しの CondPattern の解説に次のような記述がある。

(RewriteCond の構文: RewriteCond TestString CondPattern [flags])

CondPattern is the condition pattern, a regular expression which is applied to the current instance of the TestString. TestString is first evaluated, before being matched against CondPattern.

CondPattern is usually a perl compatible regular expression, but there is additional syntax available to perform other useful tests against the Teststring:

(省略)

(引用者訳: CondPatternTestString に対して適用される正規表現パターンです。 TestString の内容が評価された後に、 CondPattern での検査が行われます。

CondPattern は Perl 形式の正規表現を基本としますが、 TestString の検査を便利にするために、次の構文が追加で利用可能になっています。

(省略)
)

TestString の内容が評価された後に」 CondPattern での検査が行われる、というのは、
TestString${(プログラム名):(標準入力)} が指定されていた場合に、その (プログラム) に (標準入力) を渡し、その結果受け取る 標準出力 に対して検査が行われることを意味する。
 
また、② については、公式ドキュメント1 の 「RewriteRule Directive」の見出しの Substitution の解説に次のような記述がある。

(RewriteRule の構文: RewriteRule Pattern Substitution [flags])

In addition to plain text, the Substitution string can include

  1. back-references ($N) to the RewriteRule pattern
  2. back-references (%N) to the last matched RewriteCond pattern
  3. server-variables as in rule condition test-strings (%{VARNAME})
  4. mapping-function calls (${mapname:key|default})

(引用者訳: Substitution には通常のテキストの他、次のものを含めることが出来ます。

  • $N: パート Pattern に対する後方参照
  • %N: 対応する RewriteCond の パート CondPattern に対する後方参照のうち、最も後ろのもの
  • %{(変数名)}: サーバ変数 (RewriteCondTestString と同じ形式)
  • ${(マップ名):(キー)|(初期値)}: マッピング関数の呼び出し
    )

(①②について、公式ドキュメントの説明 ここまで)

よって、

RewriteCond ${ipcheck:%{REMOTE_ADDR}} ^BLOCK:(.+)$
RewriteRule ^ /BBASN_429.php?reason=%1 [END,E=BBASN_BLOCKED:1]

と設定することにより、 プログラム ipcheck が標準出力で "BLOCK:(理由)" を返した場合に、
/BBASN_429.php?reason=(理由) に転送することが出来る。

RewriteRuleflagsEND は、以降のリライト処理をすべてキャンセルする指定であり、
E については、2-3節で説明する。

2-3. 環境変数 について

  • RewriteRuleflags パートで [E=(環境変数名):(値)] のようにすると、環境変数を定義、代入できる。
  • RewriteCondTestString パートで環境変数を利用したい場合 %{ENV:(環境変数名)} のように指定する。
  • 環境変数は、リライト後に表示される php プログラムの中で、 getenv((環境変数名)) により取得できる。
公式ドキュメントによる説明

mod_rewrite の公式ドキュメント1 に拠れば、ディレクティブ RewriteRule の構文は
RewriteRule Pattern Substitution [flags]
のようになっている。
flags パートで [E=(環境変数名):(値)] のようにすると、環境変数を定義、代入できる。

このことは公式ドキュメント1の RewriteRule Directive 見出しのフラグの説明テーブル に次のように書かれている。

Flag and syntax Function
env|E=[!]VAR[:VAL] Causes an environment variable VAR to be set (to the value VAL if provided). The form !VAR causes the environment variable VAR to be unset. details ...

(引用者訳:

フラグおよび構文 機能
env|E=[!]VAR[:VAL] 環境変数 VAR を定義 (し、与えられている場合は その値を VAL に) する。 !VAR 形式の場合は、環境変数 VAR を削除する。 (詳細 ...)

)

ちなみに、[E=(環境変数名):(値)](値) では $N および %N による後方参照が利用できる。…③

また、環境変数は CGI プログラムでも利用できる。…④
(つまり、リライト後に表示される php プログラムの中で利用できる。)

③④のことは、 RewriteRule のフラグについて説明している公式ドキュメント2の E|env 見出し において、次のように書かれている。

The full syntax for this flag is:

[E=VAR:VAL]
[E=!VAR]

VAL may contain backreferences ($N or %N) which are expanded.

(省略)

Environment variables can then be used in a variety of contexts, including CGI programs, other RewriteRule directives, or CustomLog directives.

(引用者訳: フラグ E の構文は

[E=VAR:VAL]
[E=!VAR]

です。
VAL には 後方参照 ($N または %N) を含めることが出来ます。

(省略)

環境変数 は CGI プログラム、 他の ディレクティブ RewriteRule、 ディレクティブ CustomLog など、様々な場所で利用できます。
)

環境変数はもちろん RewriteCondTestString パートでも利用できるが、その場合は %{ENV:(環境変数名)} のようにする。

このことは公式ドキュメント1RewriteCondTestString の解説に、次のように記述がある。

%{ENV:variable}, where variable can be any environment variable, is also available. This is looked-up via internal Apache httpd structures and (if not found there) via getenv() from the Apache httpd server process.
(引用者訳: (TestStringでは) ${ENV:(環境変数名)} として、環境変数を利用することもできます。これはApacheの内部構造から検索され、(そこに見つからない場合は) Apache サーバープロセス の getenv() から検索されます。)

(公式ドキュメントによる説明 ここまで)

 
また、 (CGI/FastCGI としての) php プログラムから 環境変数を取得したい場合、getenv((環境変数名)) のようにする3
こちらはその名前の環境変数が存在しなければ false を返す3

2-4. 敢えて 直接 429 リダイレクトを行わない

RewriteRule には RewriteRule ^ - [R=429] と指定すれば ステータスコード 429 を返す機能があるが、今回は利用しない

実は RewriteRule[R=429] と指定すると、 リライト先 (Substitution) が無視される仕様になっているため、

RewriteCond ${ipcheck:%{REMOTE_ADDR}} ^BLOCK:(.+)$
RewriteRule ^ /BBASN_429.php?reason=%1 [R=429]

のようにしても、 /BBASN_429.php?reason=(理由) への内部リダイレクトが実現できない。

公式ドキュメントによる説明

このことは公式ドキュメント2 に次のように書かれている。

Use of the [R] flag causes a HTTP redirect to be issued to the browser.

(省略)

Any valid HTTP response status code may be specified, using the syntax [R=305], with a 302 status code being used by default if none is specified. The status code specified need not necessarily be a redirect (3xx) status code. However, if a status code is outside the redirect range (300-399) then the substitution string is dropped entirely

(引用者訳: [R] フラグを使うと、外部リダイレクトが発生します。

(省略)

[R=(3桁の数字)] という構文でステータスコードを指定することが出来ます。指定がない場合、デフォルトで ステータスコード 302 となります。 本来、リダイレクトを表すステータスコードは 3xx ですが、[R] フラグでは 3xx 以外の数字も指定できます。 但し、 3xx 以外を指定した場合、Substitution は無視されます
)

(公式ドキュメントによる説明 ここまで)

蛇足: ErrorDocument を使ったリダイレクト

2.4.13 以降の Apacheであれば、 ディレクティブ ErrorDocument で環境変数が使えるので、
次のようなこともできる。

環境変数 REASON をセットして

RewriteCond ${ipcheck:%{REMOTE_ADDR}} ^BLOCK:(.+)$
RewriteRule ^ - [R=429,E=REASON:%1]

ErrorDocument 429 /BBASN_429.php?reason=%{ENV:REASON}

として、429エラーを /BBASN_429.php?reason=(理由) に転送する

しかし、このやり方だと すべての 429 エラーが転送されてしまう。
よって、今回の block by ASN 以外の理由で発生した 429 エラーも転送されることになってしまい、意図せぬ挙動になる恐れがある。加えて将来別の仕組みで 429 エラーを使いたいときに競合してしまう可能性もあるので、避けたほうが無難だろう。

(蛇足 ここまで)

そこで、Apache で [R=429] として ステータスコード 429 を返させるのは諦める
その代わり、 php ( /BBASN_429.php ) のほうで ステータスコード 429 を返させることにする。

この場合、 Apache のほうは

RewriteCond ${ipcheck:%{REMOTE_ADDR}} ^BLOCK:(.+)$
RewriteRule ^ /BBASN_429.php?reason=%1 [END,E=BBASN_BLOCKED:1]

とすればよい。

  • [END] フラグは、後続のリライト処理をすべて無視するための指定である。
  • 環境変数 BBASN_BLOCKED を設定しているのは、/BBASN_429.php へのアクセスが Apache による内部リダイレクトで行われていることを示すためである。
    • /BBASN_429.php 内で 環境変数 BBASN_BLOCKED を発見できた場合
      → 内部リダイレクト によりアクセスされている。
      → ステータスコード 429 を返せばよい
    • 発見できなかった場合
      → 内部リダイレクト以外でアクセスされている。
      (例えば クライアントが直接 /BBASN_429.php をリクエストしている、など)
      → ステータスコード 403 を返し、アクセスを禁止したほうが良い

2-5. php で ステータスコードとヘッダを指定する

2-4節で、 /BBASN_429.php のほうで ステータスコード 429 を返させることにした。
このことは、 php ファイルに http_response_code(429); を付ければよい。

また、 MDN Web Docs456 によれば、 ステータスコード 429 を返す場合、 ヘッダ Retry-After を定義して、「再度アクセス可能になる時刻」を明示してもよいそうだ。

例えば、「日本時間で 2025/10/22 10:05:38 以降に再度アクセス可能になります」と言いたい場合は、ヘッダ Retry-After: Wed, 22 Oct 2025 01:05:38 GMT を設定すればよい。
これは php ファイルに header("Retry-After: Wed, 22 Oct 2025 01:05:38 GMT"); を入れればよい。

2-5-1. 例

以上を踏まえ、 /BBASN_429.php?reason=AS65536;2025-10-22_00:55:38;60;32;30;600 にアクセスすると、

  • そのアクセスが 内部リダイレクトではない場合は
    ステータスコード 403 を返す

  • 内部リダイレクトの場合は
    ステータスコード 429, ヘッダ Retry-After: Wed, 22 Oct 2025 01:05:38 GMT となり、
    中身が

    429 Too Many Requests

    お使いのプロバイダ (AS65536) は
    2025-10-22 00:55:38 (GMT) の時点で、過去60秒間に当サーバへ32回アクセスしています。
    60秒間当たりのアクセス数が30回を超えた場合、恐れ入りますがアクセスを600秒間制限させていただいております。
    2025-10-22 01:05:38 (GMT) までお待ちください

    となる

ようにするには、/BBASN_429.php を 次のようにすればよい。

/BBASN_429.php
/BBASN_429.php
<?php
if (getenv('BBASN_BLOCKED') === false){
    /*
     * Apache から BBASN_BLOCKED を受け取っていない場合
     * → 内部リダイレクト以外で /BBASN_429.php へアクセスしている
     * → そのアクセスを禁止する
     */
    http_response_code(403);
    exit("このファイルに直接アクセスしないでください");
}

// 以下、内部リダイレクトの場合
function reject(){
    /*
     * URLパラメータが想定外の形式の場合、 500 Internal Server Error を返す
     */
    http_response_code(500);
    exit("内部エラー");
}
function reject_if($cond){
    if ($cond) reject();
}
function reject_if_not_match($pattern, $subject, &$matches = null){
    reject_if(!preg_match($pattern, $subject, $matches));
}

/*
 * 1. ?reason=(AS番号);(ブロック開始時刻);(監視期間);\
 *    (監視期間内アクセス回数);(回数上限);(禁止期間)
 *    の形式であることを確認する
 */
reject_if(!array_key_exists('reason', $_GET));
$reason = explode(';', $_GET['reason']);
reject_if(count($reason) != 6);
list($asn, $dt_str, $monitor_span, $count, $limit, $block_span) = $reason;

/*
 * 2. (AS番号) ~ (禁止期間) のパターンを確認する
 */
$matches_dt_str = [];
reject_if_not_match('/^AS[0-9]+$/', $asn);             // AS番号
reject_if_not_match(                                   // ブロック開始時刻
     '/^([0-9]{4})-([0-9]{1,2})-([0-9]{1,2})_'
    .'([0-9]{1,2}):([0-9]{1,2}):([0-9]{1,2})$/',
    $dt_str,
    $matches_dt_str
);
reject_if_not_match('/^[1-9][0-9]*$/', $monitor_span); // 監視期間
reject_if_not_match('/^[1-9][0-9]*$/', $count);        // 監視期間内アクセス回数
reject_if_not_match('/^[1-9][0-9]*$/', $limit);        // 回数上限
reject_if_not_match('/^[1-9][0-9]*$/', $block_span);   // 禁止期間

/*
 * 3. ブロック開始時刻とブロック終了時刻を
 *    DateTimeImmutable 型オブジェクトにする
 */
list($_, $year, $month, $day, $hour, $minute, $second) = $matches_dt_str;
$block_start_dt = new DateTimeImmutable(
    "$year-$month-$day" . 'T' . "$hour:$minute:$second" . 'Z'
);
$block_end_dt = $block_start_dt->modify("+ $block_span second");

/*
 * 4. レスポンスを返す
 */
$block_end_http_date = $block_end_dt->format('D, d M Y H:i:s') . ' GMT';
http_response_code(429);
header("Retry-After: $block_end_http_date");

$block_start = $block_start_dt->format('Y-M-d H:i:s') . ' (GMT) ';
$block_end   = $block_end_dt  ->format('Y-M-d H:i:s') . ' (GMT) ';

echo <<<content
<h1>429 Too Many Requests</h1>
お使いのプロバイダ ($asn) は<br />
$block_start の時点で、過去 $monitor_span 秒間に当サーバへ
$count 回アクセスしています。<br />
$monitor_span 秒間当たりのアクセス数が $limit 回を超えた場合、
恐れ入りますがアクセスを $block_span 秒間制限させていただいております。<br />
$block_end までお待ちください
content;

(/BBASN_429.php ここまで)

(3章 手順4 のphpプログラムと全く同じである)

3. 作ってみる

特定の IPアドレスだけアクセス拒否するのを php プログラムで実現しよう。

  1. 第1回, 第2回(前回) の手順をすべて実施する
     
  2. sudo nano /etc/httpd/conf/block_by_ASN.conf を実行して、 /etc/httpd/conf/block_by_ASN.conf の内容を次のものに書き換える
    /etc/httpd/conf/block_by_ASN.conf
    RewriteEngine On
    RewriteMap ipcheck "prg:/usr/bin/sudo -u apache -g apache /usr/bin/php /usr/local/bin/BBASN/ipcheck.php"
    RewriteCond ${ipcheck:%{REMOTE_ADDR}} ^BLOCK:(.+)$
    RewriteRule ^ /BBASN_429.php?reason=%1 [END,E=BBASN_BLOCKED:1]
    
    • (ファイルパスは 第1回 の手順10のものを使うこと)
    • (変更箇所は3行目以降である)
    • この書き換えで、Apache はプログラム ipcheckBLOCK:(理由) を返した場合に
      /BBASN_429.php?reason=(理由) に内部リダイレクトするようになる。
      このとき、環境変数として BBASN_BLOCKED = "1" が与えられる。
       
  3. sudo nano /usr/local/bin/BBASN/ipcheck.php を実行して、
    sudo nano /usr/local/bin/BBASN/ipcheck.php の内容を次のものに書き換える
    /usr/local/bin/ipcheck.php
    <?php
    $db = new SQLite3('/usr/local/bin/BBASN/log.db');
    while (($line = fgets(STDIN)) !== false){
        $ip = trim($line);
        $db->exec(<<<SQL
            INSERT INTO accesses(ip) VALUES ('$ip');
        SQL);
        // 追記箇所 ここから
        if ($ip == ※※※※)
            echo "BLOCK:AS65536;2025-10-22_00:55:38;60;32;30;600\n";
        else
        // 追記箇所 ここまで
            echo "OK\n";
        fflush(STDOUT);
    }
    $db->close();
    
    • ※※※※ には、アクセス拒否したい IP アドレスを入れる
    • アクセス拒否したい IP アドレスの場合、 "BLOCK:(理由)\n" を、
      それ以外の場合、"OK\n" を、
      標準出力するようになった
      • "BLOCK:(理由)\n" にある (理由) = AS65536;2025-10-22_00:55:38;60;32;30;600 はダミーである。
    • (エディタ nano で [ /usr/local/bin/BBASN/ipcheck.php is meant to be read-only ] などの警告が出る場合があるが、無視して読み書きできる。)
       
  4. sudo nano /var/www/html/BBASN_429.php を実行し、 /var/www/html/BBASN_429.php の内容を次のようにする。
    /var/www/html/BBASN_429.php
    <?php
    if (getenv('BBASN_BLOCKED') === false){
        /*
         * Apache から BBASN_BLOCKED を受け取っていない場合
         * → 内部リダイレクト以外で /BBASN_429.php へアクセスしている
         * → そのアクセスを禁止する
         */
        http_response_code(403);
        exit("このファイルに直接アクセスしないでください");
    }
    
    // 以下、内部リダイレクトの場合
    function reject(){
        /*
         * URLパラメータが想定外の形式の場合、 500 Internal Server Error を返す
         */
        http_response_code(500);
        exit("内部エラー");
    }
    function reject_if($cond){
        if ($cond) reject();
    }
    function reject_if_not_match($pattern, $subject, &$matches = null){
        reject_if(!preg_match($pattern, $subject, $matches));
    }
    
    /*
     * 1. ?reason=(AS番号);(ブロック開始時刻);(監視期間);\
     *    (監視期間内アクセス回数);(回数上限);(禁止期間)
     *    の形式であることを確認する
     */
    reject_if(!array_key_exists('reason', $_GET));
    $reason = explode(';', $_GET['reason']);
    reject_if(count($reason) != 6);
    list($asn, $dt_str, $monitor_span, $count, $limit, $block_span) = $reason;
    
    /*
     * 2. (AS番号) ~ (禁止期間) のパターンを確認する
     */
    $matches_dt_str = [];
    reject_if_not_match('/^AS[0-9]+$/', $asn);             // AS番号
    reject_if_not_match(                                   // ブロック開始時刻
         '/^([0-9]{4})-([0-9]{1,2})-([0-9]{1,2})_'
        .'([0-9]{1,2}):([0-9]{1,2}):([0-9]{1,2})$/',
        $dt_str,
        $matches_dt_str
    );
    reject_if_not_match('/^[1-9][0-9]*$/', $monitor_span); // 監視期間
    reject_if_not_match('/^[1-9][0-9]*$/', $count);        // 監視期間内アクセス回数
    reject_if_not_match('/^[1-9][0-9]*$/', $limit);        // 回数上限
    reject_if_not_match('/^[1-9][0-9]*$/', $block_span);   // 禁止期間
    
    /*
     * 3. ブロック開始時刻とブロック終了時刻を
     *    DateTimeImmutable 型オブジェクトにする
     */
    list($_, $year, $month, $day, $hour, $minute, $second) = $matches_dt_str;
    $block_start_dt = new DateTimeImmutable(
        "$year-$month-$day" . 'T' . "$hour:$minute:$second" . 'Z'
    );
    $block_end_dt = $block_start_dt->modify("+ $block_span second");
    
    /*
     * 4. レスポンスを返す
     */
    $block_end_http_date = $block_end_dt->format('D, d M Y H:i:s') . ' GMT';
    http_response_code(429);
    header("Retry-After: $block_end_http_date");
    
    $block_start = $block_start_dt->format('Y-M-d H:i:s') . ' (GMT) ';
    $block_end   = $block_end_dt  ->format('Y-M-d H:i:s') . ' (GMT) ';
    
    echo <<<content
    <h1>429 Too Many Requests</h1>
    お使いのプロバイダ ($asn) は<br />
    $block_start の時点で、過去 $monitor_span 秒間に当サーバへ
    $count 回アクセスしています。<br />
    $monitor_span 秒間当たりのアクセス数が $limit 回を超えた場合、
    恐れ入りますがアクセスを $block_span 秒間制限させていただいております。<br />
    $block_end までお待ちください
    content;
    
    • (ファイルパスの /var/www/html の部分は、 実際の環境のドキュメントルートに合わせて書き換えること)
       
  5. 次のコマンドを実行し、/var/www/html/BBASN_429.php の所有者とパーミッションを変更する。
    所有ユーザ apache, 所有グループ apache で 所有ユーザは読み取り可能 (4)、それ以外は一切のアクセスを不能(0)にする。
    sudo chown apache:apache /var/www/html/BBASN_429.php
    sudo chmod 400 /var/www/html/BBASN_429.php
    
    • 手順4,5で、 RewriteRule/BBASN_429.php へ内部リダイレクトされた際に表示されるプログラムが用意できた
       
  6. sudo systemctl restart httpd を実行し、Apache を再起動する

4. 使ってみる

手順3でブロックした IPアドレス でサーバにアクセスすると、次のように表示される。

429 Too Many Requests

お使いのプロバイダ (AS65536) は
2025-10-22 00:55:38 (GMT) の時点で、過去60秒間に当サーバへ32回アクセスしています。
60秒間当たりのアクセス数が30回を超えた場合、恐れ入りますがアクセスを600秒間制限させていただいております。
2025-10-22 01:05:38 (GMT) までお待ちください

それ以外の IPアドレスでサーバにアクセスすると、通常通り webページが表示される。


目次:

前回:

次回:
(執筆中)

  1. https://httpd.apache.org/docs/current/en/mod/mod_rewrite.html 2 3 4 5

  2. https://httpd.apache.org/docs/current/en/rewrite/flags.html 2

  3. https://www.php.net/manual/ja/function.getenv.php 2

  4. https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/429

  5. https://developer.mozilla.org/ja/docs/Web/HTTP/Reference/Headers/Retry-After

  6. https://developer.mozilla.org/ja/docs/Web/HTTP/Reference/Headers/Date

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?