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