PHP 不正なURLで接続してきたIPアドレスを.htaccessのDeny fromに書き込む

## 概要
この日記「[個人情報を抜かれる危険性を認識...](https://qiita.com/Yamine1San/items/4262a1206f0aa034fbb9)」に書いた通りIPアドレスをバンする処理を書いた。.htaccessを編集するのはWPのSiiteguardなどもやってるし、やってもいいでしょ。知らんけど。

## 処理概要

許可していないIPアドレスからadministratorやphpmyadminを含んだURLで接続してきたら.htaccessにDeny fromで書き込む。こんな感じで、「START IP BANNING」～「END IP BANNING」の中を書き換える。存在しない場合は末尾に追記。テストも適当に書いたし大丈夫じゃろ。知らんけど。

```.htaccess
## START IP BANNING (written by ipban.php)
Order Deny,Allow
# 2021-07-30 15:51:39
Deny from 111.11.11.1
# 2021-07-30 15:51:40
Deny from 111.11.11.2
## END IP BANNING (written by ipban.php)
```

## サンプルのURL

<b>※②を踏むと①も拒否されるようになる</b>

①通常使用のリクエストは問題ない
[https://yamine1san.ddns.net/qiita/apache-ipban/index.php](https://yamine1san.ddns.net/qiita/apache-ipban/index.php)

②phpmyadminという文字列が入っているのでアウト
[https://yamine1san.ddns.net/qiita/apache-ipban/phpmyadmin/](https://yamine1san.ddns.net/qiita/apache-ipban/phpmyadmin/)

## プログラム

### 呼び出し

```index.php
<?php
require 'config.php';
require 'ipban.php';

$ipban = \apache\ipban::exec($_SERVER['REMOTE_ADDR'], $_SERVER['REQUEST_URI']);

if (1 === $ipban->return_code) {
    echo 'あなたのIPアドレス['.$_SERVER['REMOTE_ADDR'].']からの接続を禁止します。';
    exit;
}

// 通常の処理
echo '問題の無いURLもしくは許可IPアドレスのためバン処理は何もされませんでした。';
exit;
```

### config

```config.php
<?php

namespace apache;

/**
 *
 */
class config {

    /**
     * 書き込みをする.htaccessのパス
     */
    const HTACCESS_PATH = '/opt/hoge/web/.htaccess';

    /**
     * .htaccessに書き込む開始マーク
     */
    const HTACCESS_IP_BAN_COMMENT_STR = '## START IP BANNING (written by ipban.php)';

    /**
     * .htaccessに書き込む終了マーク
     */
    const HTACCESS_IP_BAN_COMMENT_END = '## END IP BANNING (written by ipban.php)';

    /**
     * 制限をかけないIPアドレス一覧
     */
    const ACCESS_RESTRICTION_ALLOWED_IPS = [
        '127.0.0.1' => null,
        '1.2.3.4' => null,
        '5.6.7.8' => null,
    ];

    /**
     * 一発でIPバンにするURIの正規表現
     */
    const ACCESS_RESTRICTION_URI_PATTERNS = [
        '/\/\..*/i',
        '/.*(administrator|phpmyadmin|composer|nbproject|vendor|eval-stdin\.php|\.well-known|xdebug|wp-admin|wp-content|chmod\+[0-9]{3,4}|rm\+-rf).*/i',
    ];

    /**
     * バンの期間
     */
    const HTACCESS_IP_BAN_PERIOD = '+1 week';

}
```

### class ipaban

```ipban.php
<?php

namespace apache;

/**
 *
 */
class ipban {

    /**
     * 0以上は正常処理
     * -2:.htaccessの書き込み権限が無いエラー
     * -1:$remote_addrのIPアドレス形式エラー
     *  1:バン
     *  2:許可IP
     *  3:バン対象のURIではない
     *
     * @var int
     */
    public $return_code = 0;

    /**
     * @param $remote_addr
     * @param $request_uri
     * @return static
     */
    public static function exec($remote_addr, $request_uri) {

        $ins = new static();

        // IPアドレスのチェック
        $check_ip_result = $ins->check_ip($remote_addr, config::ACCESS_RESTRICTION_ALLOWED_IPS);
        if (0 > $check_ip_result) {
            trigger_error(__METHOD__.'(): remote_addrのチェックでエラー', E_USER_WARNING);
            $ins->return_code = -1;
            return $ins;
        }
        else if (1 === $check_ip_result) {
            $ins->return_code = 2;
            return $ins;
        }

        // URIのチェック
        if (true === $ins->check_uri($request_uri, config::ACCESS_RESTRICTION_URI_PATTERNS)) {
            $ins->return_code = 3;
            return $ins;
        }

        // .htaccessの作成、書き込み権限の取得
        if (1 !== $ins->check_htaccess(config::HTACCESS_PATH)) {
            trigger_error(__METHOD__.'(): .htaccessのチェックでエラー', E_USER_WARNING);
            $ins->return_code = -2;
            return $ins;
        }

        $handle = fopen(config::HTACCESS_PATH, 'r+');
        $flock = flock($handle, LOCK_EX);

        // ファイルロックをできなかった時はひとまず正常終了とする
        if (! $flock) {
            $ins->return_code = 4;
            return $ins;
        }

        $contents = stream_get_contents($handle);

        $new_contents = $ins->new_contents($remote_addr, $contents);

        // 上書き保存
        ftruncate($handle, 0);
        rewind($handle);
        fputs($handle, $new_contents);
        fclose($handle);

        $ins->return_code = 1;
        return $ins;
    }

    /**
     * @param string $remote_addr
     * @param string $old_contents
     * @return string
     */
    public function new_contents($remote_addr, $old_contents) {

        $contents = str_replace(["\r\n", "\r"], "\n", $old_contents);
        $new_contents = [];
        $new_contents_front = '';
        $new_contents_back = '';
        $new_ipban_contents = [];
        $new_ipban_contents[] = config::HTACCESS_IP_BAN_COMMENT_STR;
        $new_ipban_contents[] = 'Order Deny,Allow';

        $str_pos = strpos($contents, config::HTACCESS_IP_BAN_COMMENT_STR);
        $end_pos = strpos($contents, config::HTACCESS_IP_BAN_COMMENT_END);

        // 開始コメントが存在する時
        if (FALSE !== $str_pos) {
            $new_contents_front = substr($contents, 0, $str_pos - 1);

            if (FALSE !== $end_pos) {
                $new_contents_back = substr($contents, $end_pos + strlen(config::HTACCESS_IP_BAN_COMMENT_END) + 1);
            }

            $ipban_contents_arr = explode("\n", substr($contents, $str_pos));
            $ipban_list_tmp = [];
            $i = 0; // IPアドレス追加時にインクリメント
            $expired = 0;
            foreach ($ipban_contents_arr as $str) {

                // "期限切れ"はipban_list_tmpに入れない
                if (1 === $expired) {
                    $expired = 0;
                    continue;
                }

                // # 2021-07-30 00:00:00
                if (preg_match('/^# [0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$/', $str)) {
                    // "期限切れ"はipban_list_tmpに入れない
                    if (time() > strtotime(config::HTACCESS_IP_BAN_PERIOD, strtotime(substr($str, 2)))) {
                        $expired = 1;
                        continue;
                    }
                    $ipban_list_tmp[$i]['period'] = $str;
                }

                // # Deny from 192.168.0.11
                if (preg_match('/^Deny from (([1-9]?[0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]).){3}([1-9]?[0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/', $str)) {
                    $ipban_list_tmp[$i]['deny'] = $str;
                    $i++;
                }
            }

            // ipban_list_tmpをipアドレスをキー値にして重複削除
            $ipban_list_map = [];
            foreach ($ipban_list_tmp as $arr) {
                // 期限「# 2021-07-30 00:00:00」が連続してコメントに存在した時に発生する処理
                if (! array_key_exists('deny', $arr)) {
                    continue;
                }
                $ipban_list_map[$arr['deny']] = $arr;
            }
            $ipban_list_map['Deny from '.$remote_addr]['period'] = '# '.date('Y-m-d H:i:s');
            $ipban_list_map['Deny from '.$remote_addr]['deny'] = 'Deny from '.$remote_addr;

            // new_ipban_contentsへ
            foreach ($ipban_list_map as $arr) {
                // IPアドレスのみ（「Deny from 192.168.0.11」が連続）で期限が無い場合はperiodが無いので無期限として追加
                $new_ipban_contents[] = array_key_exists('period', $arr) ? $arr['period'] : '# Indefinite period';
                $new_ipban_contents[] = $arr['deny'];
            }
        }
        // 開始コメントが存在しない時
        else {
            $new_contents[] = $contents;
            $new_ipban_contents[] = '# '.date('Y-m-d H:i:s');
            $new_ipban_contents[] = 'Deny from '.$remote_addr;
        }

        if ('' !== $new_contents_front) {
            $new_contents[] = $new_contents_front;
        }

        $new_ipban_contents[] = config::HTACCESS_IP_BAN_COMMENT_END;
        $new_contents[] = implode("\n", $new_ipban_contents);

        if ('' !== $new_contents_back) {
            $new_contents[] = $new_contents_back;
        }
        return implode("\n", $new_contents);
    }

    /**
     * IPアドレスのチェック（CodeIgniterのForm_validationより）
     *
     * @param string $ip
     * @param string $options 'ipv4' or 'ipv6' to validate a specific IP format
     * @return bool
     */
    public function valid_ip($ip, $options = null) {
        if (! is_null($options)) {
            $which = strtolower($options);
            if ('ipv4' === $which) {
                $options = 1048576;
            }
            else if ('ipv6' === $which) {
                $options = 2097152;
            }
        }
        return (bool)filter_var($ip, 275, $options);
    }

    /**
     * @param string $ip
     * @param array $allowed_ips
     * @return int
     */
    public function check_ip($ip, $allowed_ips) {

        if (! $this->valid_ip($ip, 'ipv4')) {
            return -1;
        }

        if (array_key_exists($ip, $allowed_ips)) {
            return 1;
        }

        return 0;
    }

    /**
     * @param string $uri
     * @param array $patterns
     * @return bool
     */
    public function check_uri($uri, $patterns) {
        foreach ($patterns as $pattern) {
            if (preg_match($pattern, $uri)) {
                return false;
            }
        }
        return true;
    }

    /**
     * @param string $path
     * @return int
     */
    public function check_htaccess($path) {
        if (! is_file($path)) {
            @touch($path);
        }

        if (! is_file($path)) {
            return -1;
        }

        if (! is_readable($path) || ! is_writable($path)) {
            // TODO テスト
            $user = exec('whoami');
            if ($user) {
                @chown($path, $user);
            }
            @chmod($path, 0644);
        }

        if (! is_readable($path) || ! is_writable($path)) {
            return -2;
        }

        return 1;
    }
}
```

### テスト

```ipbanTest.php
<?php

use apache\ipban;
use apache\config;
use PHPUnit\Framework\TestCase;

/**
 * Class ipbanTest
 */
class ipbanTest extends TestCase {

    /**
     *
     */
    public function test_check_ip() {
        $ipban = new ipban();
        // IPアドレス形式エラー
        $this->assertSame(-1, $ipban->check_ip('zzzzzzzzzz', []));
        // 許可IP
        $this->assertSame(1, $ipban->check_ip('127.0.0.1', ['127.0.0.1' => null]));
        // 許可していないIP
        $this->assertSame(0, $ipban->check_ip('127.0.0.2', ['127.0.0.1' => null]));
    }

    /**
     *
     */
    public function test_check_uri() {
        $ipban = new ipban();
        // アウト
        $this->assertSame(false, $ipban->check_uri('/abcd/.git/config', config::ACCESS_RESTRICTION_URI_PATTERNS));
        $this->assertSame(false, $ipban->check_uri('/.project', config::ACCESS_RESTRICTION_URI_PATTERNS));
        $this->assertSame(false, $ipban->check_uri('/composer.json', config::ACCESS_RESTRICTION_URI_PATTERNS));
        $this->assertSame(false, $ipban->check_uri('/admin/phpmyadmin', config::ACCESS_RESTRICTION_URI_PATTERNS));
        $this->assertSame(false, $ipban->check_uri('/administrator/user-list', config::ACCESS_RESTRICTION_URI_PATTERNS));
        $this->assertSame(false, $ipban->check_uri('/vendor/phpunit', config::ACCESS_RESTRICTION_URI_PATTERNS));
        $this->assertSame(false, $ipban->check_uri('/eval-stdin.php', config::ACCESS_RESTRICTION_URI_PATTERNS));
        // 大丈夫
        $this->assertSame(true, $ipban->check_uri('/yamine1san', config::ACCESS_RESTRICTION_URI_PATTERNS));
    }

    /**
     *
     */
    public function test_new_contents() {
        $ipban = new ipban();

        // 追加される事の確認
        $old_contents = "";
        $new_contents = $ipban->new_contents('192.168.0.1', $old_contents);
        $this->assertSame(true, (0 < strpos($new_contents, '# '.date('Y-m-d H:i:s'))));
        $this->assertSame(true, (0 < strpos($new_contents, 'Deny from 192.168.0.1')));

        $old_period = '# '.date('Y-m-d H:i:s', strtotime('-1 day'));
        // 既に存在する時に期限が更新される事、前後の内容が消されない事の確認
        $old_contents = "
#aaaaaaaaaaaaaa
".config::HTACCESS_IP_BAN_COMMENT_STR."
{$old_period}
Deny from 192.168.0.1
".config::HTACCESS_IP_BAN_COMMENT_END."
#bbbbbbbbbbbbbb
";
        $new_contents = $ipban->new_contents('192.168.0.1', $old_contents);
        // 前の内容が残っている事
        $this->assertSame(true, (0 < strpos($new_contents, '#aaaaaaaaaaaaaa')));
        // 古い期限が消えた事
        $this->assertSame(false, strpos($new_contents, $old_period));
        // 新しい期限になった事
        $this->assertSame(true, (0 < strpos($new_contents, '# '.date('Y-m-d H:i:s'))));
        $this->assertSame(true, (0 < strpos($new_contents, 'Deny from 192.168.0.1')));
        // 後の内容が残っている事
        $this->assertSame(true, (0 < strpos($new_contents, '#bbbbbbbbbbbbbb')));
        // 順番が正しい
        $this->assertSame(true, strpos($new_contents, '#aaaaaaaaaaaaaa') < strpos($new_contents, '#bbbbbbbbbbbbbb'));
    }
}
```