2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-07-30

概要

この日記「個人情報を抜かれる危険性を認識...」に書いた通り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

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

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

②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'));
    }
}
2
3
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
2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?