135. 洪水控制防护flood
你的网站可能有一些很重的操作,比如给客户提供价格查询,产品数量达到了数千万,此时查询一次需要消耗很多的系统资源,如果有恶意攻击者频繁发起查询,那么系统会出现严重的性能问题,此时你需要一个进行频率限制的机制,限定一个用户在设定时间段内最多执行多少次查询;频率限制还有很多用处,比如防止用户进行大量登录尝试以猜测密码,此时应在设定时间段内错误次数达到某个阀值就拒绝登录,再比如邮件或短信的发送频率限制等等,本质上频率限制就是进行洪水控制的。
洪水控制器:
在Drupal中洪水控制器就是实现以上频率限制机制的一个工具,服务定义如下:
服务id:flood
类:Drupal\Core\Flood\DatabaseBackend
获取方法:\Drupal::flood();
用于控制一些事情在一定时间段内被执行的最大次数。在实现上该服务将事务执行记录保存到数据库中,通过分析这些记录以判断是否频率过高,数据库表为“flood”,各字段含义如下:
fid:
事件id,整数,自增,主键
event:
事件名称,字符串,最长64字符
identifier:
用户唯一标识符,字符串,最长128,用于唯一标识一个用户,通常为ip,或用户账号
timestamp:
事件发生的时间戳
expiration:
保存记录的到期时间戳,超过该时间记录会被计划任务自动清除
方法介绍:
public function register($name, $window = 3600, $identifier = NULL)
将一个需要进行频率控制的事件记录到数据库中,该事件每次执行后都应该调用该方法进行记录,这样才能分析频率,参数如下:
$name:
字符串值,事件名称,为避免冲突建议以模块名做前缀,如mymodule.my_event
$window:
本条记录有效时间长度,以秒为单位的一个整数,默认为3600(一小时),表示从当前请求时间开始计算,超过该时间段后,记录会被计划任务清理
$identifier:
用户唯一标识符,默认为null,此时采用客户端ip
public function clear($name, $identifier = NULL)
清除一条事件记录,参数含义和register方法一样
public function isAllowed($name, $threshold, $window = 3600, $identifier = NULL)
判断事件次数是否达到阀值,如果没有,返回true,表示允许事件再次发生,否则返回false,参数如下:
$name:
事件名,同register方法一样
$threshold:
一个整数,表示事件次数阀值,当大于等于该值时将返回false
$window:
一个以秒为单位的整数,默认为3600秒,表示从当前请求向前的该时间段内事件是否达到阀值,该参数虽然名字和register方法一样但含义却不相同,在该方法中表示阀值判断的时间段,而在register方法中表示记录有效期,前者应小于后者,通常采用相同的值。
$identifier:
用户唯一标识符,同register方法一样,调用时应采用相同的值
public function garbageCollection()
清理失效记录,超期时间小于当前请求时间的记录会被清理,该方法在计划任务中执行,详见系统模块的计划任务函数:system_cron()
内存型洪水控制器:
类:\Drupal\Core\Flood\MemoryBackend
这被用来在单个请求中进行洪水控制,数据记录在内存中,不能跨请求存在,接口相同
关于用户唯一标识符:
该标识符的选取应依据实际情况而定,登录用户推荐用账户id,对于匿名用户而言,通常选择ip即可,但如果你使用了固定ip的反向代理,此时需要确保该标识符为用户真实ip;现在有很多局域网用户共享相同的公网ip,你可能会想到为了进一步精确标识用户,而在标识符中加入用户代理信息(浏览器标识符),这样确实能够精确标识,但是却为攻击者提供了绕开的方法,使得洪水攻击形同虚设,这源于在HTTP协议中用户代理信息是由客户端设置的,通常攻击者使用的工具并不是浏览器,而是直接编写面向HTTP协议的程序,这样在每次请求中设置不同的代理信息就能轻易绕开频率限制,而客户端ip是由系统从TCP数据包中提取的,难以被伪造,所以相对安全。
在系统中的应用:
这被用在了登录限制中,默认一小时内密码错误达到50次,即暂时不允许登录,详见以下配置对象:
\Drupal::config('user.flood');
除此外还被用在电商模块commerce、联系表单模块contact等地方
文件型洪水控制器:
这里提供一个文件类型的洪水控制器,是多年前云客因项目需要而写,和drupal无关,是一个独立运用的类库,仅供参考:
<?php
/**
* 根据客户端ip限制用户访问频率,数据存放在文件系统中
* 默认不允许使用代理,在允许使用代理时限制可能被绕过
* by:yunke
*/
class clientAccessLimit
{
protected $defualt_option = array(
"dir" => "yunke", //储存数据的目录名
"path" => '', //储存数据的目录路径,默认网站根目录
"allowProxy" => false, //禁止代理 忽略代理服务器ip
"timeInterval" => 3600, //时间间隔 1小时
"maxlimit" => 100, //在间隔期内最大允许访问量,默认每小时只能访问100次
"cacheTime" => 172800, //限制数据保存时间 默认两天 60 * 60 * 24 * 2
);
protected $option = array();
protected $dir;
protected $iPFlag;
protected $currentTime;
/**
* 可传递选项数组以改变该类行为
*
* @param array $option
*/
public function __construct($option = array())
{
$this->defualt_option["path"] = $_SERVER['DOCUMENT_ROOT'];
$option += $this->defualt_option;
$option['path'] = rtrim($option['path'], '\\\/');
$option['dir'] = rtrim($option['dir'], '\\\/');
$dir = $option['path'] . "/" . $option['dir'];
if (!is_dir($dir)) {
@mkdir($dir, 0777, true);
}
if ($option['timeInterval'] > $option['cacheTime']) {
throw new \LogicException("间隔时间必须小于文件缓存时间");
}
$this->option = $option;
$this->dir = $dir . "/";
$this->currentTime = time();
$this->clear();
$this->getIPFlag();
}
/**
* 返回true代表需要限制,不应该继续操作,
* 返回false代表可以继续操作,不受限制
*
* @return bool
*/
public function isLimit()
{
$data = $this->getData();
if (empty($data)) {
$data = array($this->currentTime);
$this->setData($data);
return false;
}
$start_time = $this->currentTime - $this->option['timeInterval'];
$newData = array();
foreach ($data as $time) {
if ($time >= $start_time) {
$newData[] = $time;
}
}
$limit = count($newData) >= $this->option['maxlimit'] ? true : false;
if (!$limit) {
$newData[] = $this->currentTime;
}
$this->setData($newData);
return $limit;
}
/**
* 得到用户ip旗标,用作文件名
*/
protected function getIPFlag()
{
$ipFlag = "ip_" . (string)$_SERVER['REMOTE_ADDR']; //来源于tcp数据包,不易伪造
if ($this->option['allowProxy']) {
//代理地址可伪造
if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$ipFlag .= (string)$_SERVER['HTTP_X_FORWARDED_FOR'];
}
if (isset($_SERVER['HTTP_CLIENT_IP'])) {
$ipFlag .= (string)$_SERVER['HTTP_CLIENT_IP'];
}
}
$this->iPFlag = substr($ipFlag, 0, 255);
}
/**
* 得到用户历史访问数据
*
* @return array|mixed
*/
protected function getData()
{
$file = $this->dir . $this->iPFlag;
if (!file_exists($file)) {
return array();
}
return unserialize(file_get_contents($file));
}
/**
* 保存用户访问数据
* 访问数据为一个由历次访问时间构成的数组
*
* @param array $data
*/
protected function setData($data = array())
{
$file = $this->dir . $this->iPFlag;
$data = serialize($data);
@file_put_contents($file, $data);
}
/**
* 清理过期数据,防止无用数据堆积
*
* @return bool
*/
protected function clear()
{
$cache = $this->dir . "cache";
if (!file_exists($cache)) {
@file_put_contents($cache, $this->currentTime);
return true;
}
$lastClearTime = (int)file_get_contents($cache);
$isNeedClear = ($this->currentTime - $lastClearTime) > $this->option['cacheTime'] ? true : false;
if (!$isNeedClear) {
return true;
}
//执行过期数据清理 迭代文件
$files = scandir($this->dir);
foreach ($files as $file) {
if (strpos($file, "ip_") !== 0) {
continue;
}
$isNeedDlete = ($this->currentTime - filectime($this->dir . $file)) > $this->option['cacheTime'] ? true : false;
if ($isNeedDlete) {
unlink($this->dir . $file);
}
}
@file_put_contents($cache, $this->currentTime); //设置最后清理时间
return true;
}
}
使用:
该类仅向外提供一个公共方法:
isLimit()
返回布尔值,当为true时表示已被限制,不能继续操作