鲲圭发现 数据过滤验证:为后端业务处理构建一道安全屏障

数据过滤验证:为后端业务处理构建一道安全屏障

永远不要相信客户端的数据

数据安全防护在任何软件系统中都是不可忽视的一项任务,客户端传给服务端的数据需要经过有效性验证、权限验证等后才能进行下一步业务逻辑处理。WEB开发领域因模拟请求的成本非常低,如用户可直接在浏览器地址栏中修改请求参数,如果在对应页面没有做任何验证处理,则有可能暴露出很多系统级的错误信息,让有心黑客有机可乘。

本篇我们将基于PHP语言一步步实现一个数据验证器,对常用数据类型做校验。

验证规则定义

常见的数据类型有字符串(str)、整型(int)、布尔(bool)、浮点数(float)、数字(numeric)、数组(array)、对象(object),我们构造一个验证规则样例如下:

参数名:数据类型 => [约束项1, [约束项2 => 约束数据]]

配合一个样例能更好理解我们的定义规则:

[
   'name:str' => ['must', 'or' => 'email'],
   'email:str' => ['email'],
   'password:str' => ['must', 'length' => [6, 20]],
   'terminal:int' => ['in' => [1, 2]]
 ]

以上参数具体验证约束规则解释如下:

  • name:字符串类型,数组中的验证规则must表示name参数是必须的;验证规则or表示参数name和email必须二选一;
  • email:字符串类型,验证规则email必须是邮箱格式;
  • password:字符串类型,并且是必须的,同时字符串长度必须在6-20之间;
  • terminal:整型,因为没有must规则,所以是可选参数,如果有传值,则必须满足in规则:数据值只能是1或2。

既已定义了验证规则及写法,接下来我们将定义一个类来实现。

构造验证类

class Validator {
  
  private $_data = [];
  private $_rules = [];
  
  /**
   * 初始化验证器
   * @param array $rules 验证规则
   * @param array $data 待验证的数据集
   */
  public function __construct(array $rules, $data) {
    $this->_rules = $rules;
    $this->_data = $data;
  }
}

我们定义了一个名为Validator的类,在初始化方法中定义了两个参数:$rules用于接收规则集,$data为用户端传递的数据。有了验证规则和原始输入数据后,就可以针对每项规则进行逐一验证了。增加一个属性$_item,定义一个新的方法Validate():

private $_item = null;

/**
 * 开始验证
 * @return array 数据集
 */
public function Validate() {
  foreach($this->_rules as $item=>$terms) {
    is_array($terms) || $this->Error('约束规则必须是数组类型');
    list($item, $type) = explode(':', $item);
    $type || $this->Error('参数'.$item.'未声明数据类型']);
    $this->_item = $item;
    $this->checkType($type); // 检查数据类型
    $terms = $this->checkMust($terms); // 检查是否未必须项
    foreach($terms as $k=>$v) {
      $method = is_integer($k) ? $v : $k;
      $param = is_integer($k) ? [] : $v;
      method_exists($this, '_'.$method) || Yls::Instance()->Error('无法识别或暂未支持的验证规则:'.$method]);
      isset($this->_data[$this->_item]) && $this->{'_'.$method}($param);
    }
  }
  return $this->_data;
}

/**
 * 输出标准JSON格式错误信息
 * @param string $message 错误信息
 * @return void
 */
public function Error($message) {
  $response = json_encode([
    'status' => false, // 成功状态码
    'message' => $message // 错误信息
  ], JSON_INVALID_UTF8_SUBSTITUTE);
  
  #header('Access-Control-Allow-Origin: *'); // 有跨域需求时开启
  header('Content-Type:application/json;charset=utf-8');
  header('HTTP/1.1 200 OK');
  
  die($response);
}

数据类型及必须项的检查

/**
 * 检查数据类型
 * @param string $type 数据类型
 */
protected function checkType($type) {
  if(!isset($this->_data[$this->_item])) return;
  switch($type) {
    case 'str':
      is_string($this->_data[$this->_item]) || $this->Error('参数'.$this->_item.'必须是字符串');
    break;
    case 'int':
      is_int($this->_data[$this->_item]) || $this->Error('参数'.$this->_item.'必须是整型');
    break;
    case 'array':
      is_array($this->_data[$this->_item]) || $this->Error('参数'.$this->_item.'必须是数组');
    break;
    case 'object':
      is_a($this->_data[$this->_item], 'stdClass') || $this->Error('参数'.$this->_item.'必须是一个对象');
    break;
    case 'bool':
      is_bool($this->_data[$this->_item]) || $this->Error('参数'.$this->_item.'必须是布尔值');
    break;
    case 'float':
      is_float($this->_data[$this->_item]) || $this->Error('参数'.$this->_item.'必须是浮点数');
    break;
    case 'numeric':
      is_numeric($this->_data[$this->_item]) || $this->Error('参数'.$this->_item.'必须是数字');
    break;
    default:
      $this->Error('无法识别的数据类型:'.$this->_item);
  }
}

每种验证规则的实现

回看定义Validate()方法中的这段代码:

...
method_exists($this, '_'.$method) || Yls::Instance()->Error('无法识别或暂未支持的验证规则:'.$method]);
isset($this->_data[$this->_item]) && $this->{'_'.$method}($param);
...

我们通过对每个参数项的约束规则集进行遍历(如果元素是键值对形式,则键对应的是规则名,值对应的是规则额外约束条件,如'lenght' => [1, 2];否则元素就是约束规则名,比如 'email'),得到规则名,并调用对应的验证方法(规则名前加下划线_),比如email的验证方法为_email。我们首先来实现email规则的验证方法:

/**
 * 检查给定参数是否为合法的邮箱地址
 */
private function _email() {
  filter_var($this->_data[$this->_item], FILTER_VALIDATE_EMAIL) || $this->Error('参数'.$this->_item.'必须是一个合法的邮箱地址');
}

此种验证规则没有附带额外约束条件,最为简单。我们将实现一个带约束条件的length规则:

/**
 * 检查给定参数长度是否符合要求
 * · 当给定的$prop为数组时,如[1, 10],第一个元素表示最小长度,第二个元素表示最大长度;
 * · 当给定的$prop为整数时,表示限定必须长度
 * @param mixed $prop 长度限制选项
 */
private function _length($prop) {
  $length = is_array($this->_data[$this->_item]) ? 'count' : 'strlen';
  if(is_array($prop)) {
    $min = $prop[0] ?? null;
    $max = $prop[1] ?? null;
    
    if(!is_null($min)) {
      $length($this->_data[$this->_item]) >= $min || $this->Error('参数'.$this->_item.'长度必须大于'.$min]);
    }
    if(!is_null($max)) {
      $length($this->_data[$this->_item]) <= $max || $this->Error('参数'.$this->_item.'长度必须小于'.$max);
    }
  } elseif(is_int($prop)) {
    $prop == $length($this->_data[$this->_item]) || $this->Error('参数'.$this->_item.'长度必须为'.$prop);
  }
}

每种验证方式均可以此为扩展进行定义,不过需要说明的是,我们一开始在Validate()方法中提前调用了checkMust()方法来检查了must和or两种特殊的规则。后续可以针对业务发展不同方式的验证规则,来简写代码,提高生产力。

一些扩展的验证规则

min 规则:

/**
 * 检查给定参数是否大于等于最小值
 * @param integer $prop
 */
private function _min($prop) {
  $this->_data[$this->_item] >= $prop || $this->Error('参数'.$this->_item.'必须大于等于'.$prop);
}

案例:

[
  'age:int' => ['min' => 18]
]

max 规则:

/**
 * 检查给定参数是否小于等于最大值
 * @param integer $prop
 */
private function _max($prop) {
  $this->_data[$this->_item] <= $prop || $this->Error('参数'.$this->_item.'必须小于等于'.$prop);
}

案例:

[
  'age:int' => ['max' => 35]
]

in 规则:

/**
 * 检查给定参数是否在限制范围内
 * @param array $prop 合法选项集合
 */
private function _in($prop) {
  is_array($prop) || $this->Error('约束规则 in 的约束条件必须为数组');
  in_array($this->_data[$this->_item], $prop) || $this->Error('参数'.$this->_item.'非法');
}

案例:

[
  'gender:str' => ['in' => ['男', '女']]
]

equal 规则:

/**
 * 检查给定参数是否与指定值相等
 * @param mixed $prop
 */
private function _equal($prop) {
  $this->_data[$this->_item] == $prop || $this->Error('参数'.$this->_item.'值必须为'.$prop]);
}

案例:

[
  'key:str' => ['equal' => ['321']]
]

 date 规则:

/**
 * 检查给定参数是否为合法的日期
 * @example 2020-11-24
 */
private function _date() {
  preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $this->_data[$this->_item], $match)
  && $match && isset($match[1]) && isset($match[2]) && isset($match[3])
  && checkdate($match[2], $match[3], $match[1])
  || $this->Error('VALIDATOR_PARAM_INVALID_DATE', ['name' => $this->_item]);
}

案例:

[
  'birthday:str' => ['date']
]

datetime 规则:

/**
 * 检查给定参数是否为合法的日期时间
 * @example 2020-11-24 14:40:23
 */
private function _datetime() {
  preg_match('/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/', $this->_data[$this->_item], $match)
  && strtotime($this->_data[$this->_item]) || $this->Error('参数'.$this->_item.'不是一个有效的日期格式');
}

案例:

[
  'created:str' => ['datetime']
]

url 规则:

/**
 * 检查给定参数是否为合法的url地址
 */
private function _url() {
  if(preg_match('/^\/{2}/', $this->_data[$this->_item])) {
    $this->_data[$this->_item] = 'https:'.$this->_data[$this->_item];
  }
  filter_var($this->_data[$this->_item], FILTER_VALIDATE_URL) || $this->Error('参数'.$this->_item.'不是一个合法的url地址');
}

domain 规则:

/**
 * 检查给定参数是否为有效域名
 */
private function _url() {
  if(preg_match('/^\/{2}/', $this->_data[$this->_item])) {
    $this->_data[$this->_item] = 'https:'.$this->_data[$this->_item];
  }
  filter_var($this->_data[$this->_item], FILTER_VALIDATE_URL) || $this->Error('参数'.$this->_item.'不是一个有效的域名');
}

上面都是一些经常会使用到的数据验证方法,封装到验证规则后,你的校验工作将会变得非常轻松。考虑到会有一些特殊验证方式,我们再增加一个正则匹配验证规则:

/**
 * 检查给定参数是否符合正则条件
 * @param string $prop 正则表达式
 */
private function _regex($prop) {
  is_string($prop) || $this->Error('regex的约束条件必须为字符串');
  
  filter_var($this->_data[$this->_item], FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => $prop]])
  || $this->Error('参数'.$this->_item.'非法');
}

有了正则匹配,基本上能满足绝大部分业务验证需求了,我们的数据验证器到此也可以算做完成任务了。

一个特别的规则

程序员的思维是能让机器做的尽量让机器做。我们先来看一个例子:

$data = ['name' => 'Kunquer', 'age' => 4]; // 客户端传输的数据
Db()->Insert([
  'name' => $data['name'],
  'age' => $data['age']
]);

有没有发现有些啰嗦?其实很多业务场景在数据入库处理时,因为前端的数据参数和数据库的字段都是一致的,我们可以改进一下:

$data = ['name' => 'Kunquer', 'age' => 4]; // 客户端传输的数据
Db()->Insert($data);

这样是不是清爽多了?那当然是咯!但是这样会立即引入一个问题:如果原始数据$data不做任何验证,传入了一些不期望的数据,比如id或active等标识用户敏感信息的字段时,系统的安全性将会如风中的泡泡,一吹即破。所以我们有必要在规则里声明哪些参数被禁止/忽略掉,这就需要引入我们的特别规则 deny:

/**
 * 剔除给定的参数
 */
private function _deny() {
  if(isset($this->_data[$this->_item])) unset($this->_data[$this->_item]);
}

实现方法很简单,但能让你少敲很多重复的代码,不是吗?本文完,下一篇我们将实现一个简单的多语言翻译管理器。