大家有没有想一下一般我们可以通过$_GET或许$_POST等获取表单数据,但是输入类提供了post,get等方法,那么这种方式是多此一举还是有别的需求?如果有区别,那么区别在哪里?
这一节我们看下CI提供给我们的输入类和输出类,通过研究这两个类的源码学习下表单数据处理,安全过滤(xss-跨站脚本,csrf-跨站请求伪造),网页缓存,输出控制以及http相关的一些知识。
输入类 CI_Input
先从构造方法开始
看下构造方法做了哪些初始化
public function __construct()
{
/*
* 读取相关的配置,分别为:
* 是否销毁全局的GET数组的allow_get_array,
* 是否进行xss过滤的global_xss_filtering,
* 是否防御跨站请求伪造的csrf_protection,
* 统一换行符的standardize_newlines,
* */
$this->_allow_get_array = (config_item('allow_get_array') === TRUE);
$this->_enable_xss = (config_item('global_xss_filtering') === TRUE);
$this->_enable_csrf = (config_item('csrf_protection') === TRUE);
$this->_standardize_newlines = (bool) config_item('standardize_newlines');
//security类主要进行xss和csrf的操作,load_class我们在之前的请求处理中分析过
$this->security =& load_class('Security', 'core');
//加载utf8类,将对字符进行utf8编码处理
if (UTF8_ENABLED === TRUE)
{
$this->uni =& load_class('Utf8', 'core');
}
//该方法就是根据读取到的相关配置,进而对全局数组进行处理
$this->_sanitize_globals();
log_message('info', 'Input Class Initialized');
}
我们一般是从全局数组中获取表单数据,通过分析很明显看到是在_sanitize_globals()方法中对表单数据做了处理,看下此方法
protected function _sanitize_globals()
{
//还记得构造方法中加载的配置allow_get_array吗?
//可以看到该配置为false将会销毁全局GET数组
if ($this->_allow_get_array === FALSE)
{
$_GET = array();
}
//如果允许全局GET数组,那么对全局GET数组的数据做清洗
elseif (is_array($_GET) && count($_GET) > 0)
{
foreach ($_GET as $key => $val)
{
$_GET[$this->_clean_input_keys($key)] = $this->_clean_input_data($val);
}
}
//对全局POST数组的数据做清洗
if (is_array($_POST) && count($_POST) > 0)
{
foreach ($_POST as $key => $val)
{
$_POST[$this->_clean_input_keys($key)] = $this->_clean_input_data($val);
}
}
//对cookie做清洗
if (is_array($_COOKIE) && count($_COOKIE) > 0)
{
unset(
$_COOKIE['$Version'],
$_COOKIE['$Path'],
$_COOKIE['$Domain']
);
foreach ($_COOKIE as $key => $val)
{
if (($cookie_key = $this->_clean_input_keys($key)) !== FALSE)
{
$_COOKIE[$cookie_key] = $this->_clean_input_data($val);
}
else
{
//不合法的cookie就删掉
unset($_COOKIE[$key]);
}
}
}
//PHP_SELF是除了域名外的url段,这里清除在url中带标签的恶意脚本
$_SERVER['PHP_SELF'] = strip_tags($_SERVER['PHP_SELF']);
//如果配置中的csrf_protection为true,那么就需要调用security类对跨站请求伪造进行处理
if ($this->_enable_csrf === TRUE && ! is_cli())
{
$this->security->csrf_verify();
}
log_message('debug', 'Global POST, GET and COOKIE data sanitized');
}
_sanitize_globals()方法中我们看到对于全局数组的key和value分别使用了_clean_input_keys()和_clean_input_data()做清洗,看下这两个方法
/*
* 清洗key
* */
protected function _clean_input_keys($str, $fatal = TRUE)
{
//监测表单的key,如果key不合法就报错
if ( ! preg_match('/^[a-z0-9:_\/|-]+$/i', $str))
{
if ($fatal === TRUE)
{
return FALSE;
}
else
{
set_status_header(503);
echo 'Disallowed Key Characters.';
exit(7); // EXIT_USER_INPUT
}
}
//如果脚本内部编码支持utf8,那么对key进行utf8编码
if (UTF8_ENABLED === TRUE)
{
return $this->uni->clean_string($str);
}
return $str;
}
/*
* 清洗value
* */
protected function _clean_input_data($str)
{
//考虑到表单的value可能是数组,对value递归处理
if (is_array($str))
{
$new_array = array();
foreach (array_keys($str) as $key)
{
$new_array[$this->_clean_input_keys($key)] = $this->_clean_input_data($str[$key]);
}
return $new_array;
}
//5.4版本以下,如果magic_quotes为on,所有的'(单引号)、"(双引号)、\(反斜杠)和 NULL会被一个反斜杠
//自动转义;在 PHP 5.4.O 起将始终返回 FALSE ,关于magic_quotes见:
//http://php.net/manual/en/info.configuration.php#ini.magic-quotes-gpc
if ( ! is_php('5.4') && get_magic_quotes_gpc())
{
$str = stripslashes($str);
}
//对value进行utf8编码
if (UTF8_ENABLED === TRUE)
{
$str = $this->uni->clean_string($str);
}
//移除非打印字符
$str = remove_invisible_characters($str, FALSE);
//由于unix,windows,mac上换行符不一致,这里根据配置对换行符做一致性处理
if ($this->_standardize_newlines === TRUE)
{
return preg_replace('/(?:\r\n|[\r\n])/', PHP_EOL, $str);
}
return $str;
}
至此整个构造方法相关的初始化就分析完了,我们发现整个过程本质就做了一件事,读取相关配置对全局数组进行清洗。
获取表单数据
前文的构造方法的源码分析中我们看到需要的表单数据已经被清洗完成,那么这时候Input类提供的表单数据获取方法get(),post()本质上就是从全局数组上拿数据就行了。由于这些方法大同小异,这里以post方法为例,看下表单数据读取
/*
* post方法接受两个参数,第一个是表单的key,第二个是是否对xss(跨站脚本)做处理的可选参数
*/
public function post($index = NULL, $xss_clean = NULL)
{
return $this->_fetch_from_array($_POST, $index, $xss_clean);
}
如果仔细看其他几个获取数据的方法,会发现它们内部都调用_fetch_from_array()这个方法,该方法用来从全局数组中获取相应的数据
protected function _fetch_from_array(&$array, $index = NULL, $xss_clean = NULL)
{
//读取是否进行跨站脚本清洗的配置
is_bool($xss_clean) OR $xss_clean = $this->_enable_xss;
//判断key是否为空,如果为空就意味着读取整个全局数组
isset($index) OR $index = array_keys($array);
//如果是一次获取多个表单值,那么递归读取这些值
if (is_array($index))
{
$output = array();
foreach ($index as $key)
{
$output[$key] = $this->_fetch_from_array($array, $key, $xss_clean);
}
return $output;
}
//从全局数组中读取该值
if (isset($array[$index]))
{
$value = $array[$index];
}
/*
* key | value
* favorite[] | a
* favorite[] | b
* 数组格式的表单值的读取其key有两种风格,例如 : this->input->post('favorite') 和 this->input->post('favorite[]'),
* 接下来的这段代码就是处理key为'favorite[]'这种读取表单值的方式,注意:全局数组是不支持这种带[]的key的
* */
elseif (($count = preg_match_all('/(?:^[^\[]+)|\[[^]]*\]/', $index, $matches)) > 1)
{
$value = $array;
for ($i = 0; $i < $count; $i++)
{
$key = trim($matches[0][$i], '[]');
if ($key === '')
{
break;
}
if (isset($value[$key]))
{
$value = $value[$key];
}
else
{
return NULL;
}
}
}
else
{
return NULL;
}
//xss过滤返回表单值
return ($xss_clean === TRUE)
? $this->security->xss_clean($value)
: $value;
}
通过分析post()获取表单值的源码,发现post(),get()等方法通过调用_fetch_from_array(),其本质上是从全局数组上读取已经被清洗好了的表单值,至此读取表单数据的源码分析就完成了。
安全性处理 CI_Security
我们在源码分析中多次看到xss_clean和csrf_verify对数据做安全性处理,那么xss和csrf是什么?
- xss((Cross Site Scripting):跨站脚本攻击,为了不和层叠样式表(Cascading Style Sheets)的缩写混淆,故将跨站脚本攻击缩写为XSS,这是一种通过Web页面里插入恶意Script代码,当用户浏览该页之时,嵌入其中Web里面的Script代码会被执行,从而达到恶意攻击用户的目的。
- CSRF(Cross-site request forgery)跨站请求伪造,这是一种通过伪造会话信息,冒充用户进而骗取服务器信任的一种攻击手段。通过描述可以看到xss和csrf是不一样的,它们属于不同维度的攻击手段。
CI框架提供了CI_Security类来处理xss和csrf,通过阅读该类的源码来学习下对这两种攻击手段的处理。
xss_clean()方法
关于xss_clean要想清楚这几个问题:clean哪些东西?或者说脚本会从哪里注入进来?脚本注入的类型有哪些?
想清楚上面的这几个问题后会发现其实xss_clean的原理从大的方面来说始终围绕两点
- 转换脚本的标签为实体从而破坏脚本的执行环境
- 将一些可能为系统命令或函数的关键字给清除
接下来,我们进入xss_clean()方法中看下其实现
public function xss_clean($str, $is_image = FALSE)
{
//如果待被过滤字符是数组,递归处理它
if (is_array($str))
{
while (list($key) = each($str))
{
$str[$key] = $this->xss_clean($str[$key]);
}
return $str;
}
//移除非打印字符,这个我们在之前的源码分析第二篇中已经分析过了
$str = remove_invisible_characters($str);
//因为有些恶意字符会伪装成url编码,反正不管怎么样,先解码一下,让那些恶意字符现出原形
do
{
$str = rawurldecode($str);
}
while (preg_match('/%[0-9a-f]{2,}/i', $str));
//将标签符号转义成字符实体
$str = preg_replace_callback("/[^a-z0-9>]+[a-z0-9]+=([\'\"]).*?\\1/si", array($this, '_convert_attribute'), $str);
$str = preg_replace_callback('/<\w+.*/si', array($this, '_decode_entity'), $str);
//移除非打印字符
$str = remove_invisible_characters($str);
//将正则制表符换成空格,后文中会去除这些空格
$str = str_replace("\t", ' ', $str);
// Capture converted string for later comparison
$converted_string = $str;
//清除字符中不被允许的关键字
$str = $this->_do_never_allowed($str);
//将字符中的php标签符号转换成实体
if ($is_image === TRUE)
{
// Images have a tendency to have the PHP short opening and
// closing tags every so often so we skip those and only
// do the long opening tags.
$str = preg_replace('/<\?(php)/i', '<?\\1', $str);
}
else
{
$str = str_replace(array(''), array('<?', '?>'), $str);
}
//去除关键字中的空格
$words = array(
'javascript', 'expression', 'vbscript', 'jscript', 'wscript',
'vbs', 'script', 'base64', 'applet', 'alert', 'document',
'write', 'cookie', 'window', 'confirm', 'prompt'
);
foreach ($words as $word)
{
$word = implode('\s*', str_split($word)).'\s*';
// We only want to do this when it is followed by a non-word character
// That way valid stuff like "dealer to" does not become "dealerto"
$str = preg_replace_callback('#('.substr($word, 0, -3).')(\W)#is', array($this, '_compact_exploded_words'), $str);
}
//清除链接和图片路径中的关键字,并将链接中的标签符号转换成实体
do
{
$original = $str;
if (preg_match('/]+([^>]*?)(?:>|$)#si', array($this, '_js_link_removal'), $str);
}
if (preg_match('/
]*?)(?:\s?/?>|$)#si', array($this, '_js_img_removal'), $str);
}
if (preg_match('/script|xss/i', $str))
{
$str = preg_replace('##si', '[removed]', $str);
}
}
while ($original !== $str);
unset($original);
//清除标签属性中出现的关键字
$str = $this->_remove_evil_attributes($str, $is_image);
//有些关键字可能直接以html标签的方式出现,如果是这样,将标签符号转成实体
$naughty = 'alert|prompt|confirm|applet|audio|basefont|base|behavior|bgsound|blink|body|embed|expression|form|frameset|frame|head|html|ilayer|iframe|input|button|select|isindex|layer|link|meta|keygen|object|plaintext|style|script|textarea|title|math|video|svg|xml|xss';
$str = preg_replace_callback('#<(/*\s*)('.$naughty.')([^><]*)([><]*)#is', array($this, '_sanitize_naughty_html'), $str);
//有些关键字可能会以函数或者系统命令的方式出现,如果是这样,将函数调用的括号()转换成实体
$str = preg_replace('#(alert|prompt|confirm|cmd|passthru|eval|exec|expression|system|fopen|fsockopen|file|file_get_contents|readfile|unlink)(\s*)\((.*?)\)#si',
'\\1\\2(\\3)',
$str);
//再次清除字符中不被允许的关键字
$str = $this->_do_never_allowed($str);
//最终清洗后得到的str和最初做了清洗的str($converted_string)做比较,如果它们是一样的,
//就说明图片是安全的(true),如果不一样就说明该图片中含有xss代码,是不安全的(false)
if ($is_image === TRUE)
{
return ($str === $converted_string);
}
return $str;
}
xss_clean的源码分析到此结束了,从代码中不难发现其本质就是我们说的那两点,通过转换恶意脚本的标签符号为实体从而破坏脚本的执行环境;将恶意的函数/命令关键字删除以免在服务器被执行;
csrf_verify()方法
csrf防御一般都是通过token(令牌)实现的,其原理就是在你的页面服务端为你生成一条token,当你发送请求时携带上该token和服务端保存的token进行比对,从而判断该请求是否合法。
那么就有个疑问,跨域的ajax提交表单时怎么做csrf保护呢?其实现在的ajax请求一般都被设计成了 REST API 的方式,而 REST API天生带token,并且 REST API 原则上是屏蔽会话信息的, 也就是它不接受提交所谓的cookie,既然不接受会话信息,那也就谈不上会话的伪造了;当然token被破解又是另一说了。
CI_Security类在构造方法中对csrf相关的设置做了初始化
public function __construct()
{
//是否防御csrf
if (config_item('csrf_protection'))
{
//读取相关的变量
foreach (array('csrf_expire', 'csrf_token_name', 'csrf_cookie_name') as $key)
{
if (NULL !== ($val = config_item($key)))
{
$this->{'_'.$key} = $val;
}
}
//如果有需要,可以将该cookie挂在某个命名空间下面
if ($cookie_prefix = config_item('cookie_prefix'))
{
$this->_csrf_cookie_name = $cookie_prefix.$this->_csrf_cookie_name;
}
//生成token,该token同时会被存储在cookie中
$this->_csrf_set_hash();
}
$this->charset = strtoupper(config_item('charset'));
log_message('info', 'Security Class Initialized');
}
接着我们在csrf_verify()看下csrf的防御
public function csrf_verify()
{
//如果是非表单提交而是页面渲染的话,这时候就需要将token写入cookie中去
if (strtoupper($_SERVER['REQUEST_METHOD']) !== 'POST')
{
return $this->csrf_set_cookie();
}
//排除不需要进行csrf防御的页面
if ($exclude_uris = config_item('csrf_exclude_uris'))
{
$uri = load_class('URI', 'core');
foreach ($exclude_uris as $excluded)
{
if (preg_match('#^'.$excluded.'$#i'.(UTF8_ENABLED ? 'u' : ''), $uri->uri_string()))
{
return $this;
}
}
}
//比对客户端提交过来的token和服务端存储token,如果二者不一致,那就说明此请求是伪造的
if ( ! isset($_POST[$this->_csrf_token_name], $_COOKIE[$this->_csrf_cookie_name])
OR $_POST[$this->_csrf_token_name] !== $_COOKIE[$this->_csrf_cookie_name]) // Do the tokens match?
{
$this->csrf_show_error();
}
//从全局数组中删除该token,因为该元素只是用来校验token,不应该出现在后续的业务逻辑中
unset($_POST[$this->_csrf_token_name]);
//根据配置决定是否每次请求结束后从新生成令牌,该配置慎用,因为页面上看不见的异步请求可能会让没使用的token失效
if (config_item('csrf_regenerate'))
{
// Nothing should last forever
unset($_COOKIE[$this->_csrf_cookie_name]);
$this->_csrf_hash = NULL;
}
//重新生成token,当然如果token有存活周期的话,就不需要重新生成了
$this->_csrf_set_hash();
$this->csrf_set_cookie();
log_message('info', 'CSRF token verified');
return $this;
}
接下来我们分析下输出类。
输出类 CI_Output
输出类就像文档中说的
发送Web 页面内容到请求的浏览器,并且还能对视图进行缓存。
输出内容到浏览器
我们在控制器中定义一个方法,然后在这个方法中加载一个视图,视图中写入一段文字,并在浏览器访问,发现该视图确实被加载成功了
一般情况下,我们要想向浏览器输出内容,必须靠 echo,print_r,var_dump之类的输出函数,可是在视图加载的过程中,并没有看见这些函数,那视图中的hello world到底是如何输出的?
如果大家仔细看文档的话,输出类中有个_display()方法,文档中是这样描述该方法的(注意图中圈出的文字)
那么_display()方法是在哪里被调用?是在引导文件CodeIgniter.php中调用的
那现在我们就设想下是不是在该方法中最终输出视图中的hello world的?
进入_display()方法一探究竟
/**
*
* 仔细看下该方法的注释其实已经说的很明白了:
* 发送最终的数据以及头信息和分析数据到浏览器,并结束基准测试,并且注释中也提到了所有视图
* 数据会被自动的添加到final_output这个变量中去,那么很清楚了,我们视图中的 hello world
* 就是在这被输出的
*
*
* Display Output
*
* Processes and sends finalized output data to the browser along
* with any server headers and profile data. It also stops benchmark
* timers so the page rendering speed and memory usage can be shown.
*
* Note: All "view" data is automatically put into $this->final_output
* by controller class.
*
* @uses CI_Output::$final_output
* @param string $output Output data override
* @return void
*/
public function _display($output = '')
{
// 这里为什么不使用加载器$CI->load('Class')去加载这两个类的原因,是因为该方法可能会在输出缓存的逻辑中被调用,
// 输出缓存的逻辑是在引导文件Codeigter.php 323-326 行处,该处一旦发现缓存有效就直接向浏览器输出,这就会导致
// 代码执行不到 & get_instance() 这个方法处,也就生成不了CI_Controller的示例对象$CI,那自然也不能用加载器loader去加载类库了
$BM =& load_class('Benchmark', 'core');
$CFG =& load_class('Config', 'core');
// 如果CI_Controller类存在实例化该对象,那就说明这是正常的输出,而非缓存输出,
// 该 & get_instance()调用是Codeigter.php的,而Codeigter.php中的& get_instance()
// 是对CI_Controller中 & get_instance()方法的引用
if (class_exists('CI_Controller', FALSE))
{
$CI =& get_instance();
}
// --------------------------------------------------------------------
//这里不管你输出的是啥,都把它看作是buffer,这里的final_output是在我们
//$this->load->view('test/test')时在view函数中使用php的内置的缓存机制
//oupt_buffering生成的,关注php的内置缓存机制见:
//
if ($output === '')
{
$output =& $this->final_output;
}
// --------------------------------------------------------------------
//在输出的同时判断是不是还需要进行缓存
if ($this->cache_expiration > 0 && isset($CI) && ! method_exists($CI, '_output'))
{
$this->_write_cache($output);
}
// --------------------------------------------------------------------
// 由于该函数是终极输出函数,那自然相关的数据采集就得在这做了,包括调用基准测试类采集脚本的执行时间,
// 收集内存的使用情况等
$elapsed = $BM->elapsed_time('total_execution_time_start', 'total_execution_time_end');
if ($this->parse_exec_vars === TRUE)
{
$memory = round(memory_get_usage() / 1024 / 1024, 2).'MB';
$output = str_replace(array('{elapsed_time}', '{memory_usage}'), array($elapsed, $memory), $output);
}
// --------------------------------------------------------------------
// 是否对输出内容压缩,注意:输出压缩依赖zlib扩展
if (isset($CI) // This means that we're not serving a cache file, if we were, it would already be compressed
&& $this->_compress_output === TRUE
&& isset($_SERVER['HTTP_ACCEPT_ENCODING']) && strpos($_SERVER['HTTP_ACCEPT_ENCODING'], 'gzip') !== FALSE)
{
ob_start('ob_gzhandler');
}
// --------------------------------------------------------------------
//我们之前说了由于final_output本质上就是个一段buffer,至于让客户端怎么解析,我们只需要指定相应的mime头信息就行了
if (count($this->headers) > 0)
{
foreach ($this->headers as $header)
{
@header($header[0], $header[1]);
}
}
// --------------------------------------------------------------------
// 这段逻辑是为缓存读取服务的,正常的输出是不走这段逻辑的,注意:如果_compress_output为true,
// 也就意味着读取的缓存是被压缩了的,如果客户端不支持压缩则必须解压后才能输出
if ( ! isset($CI))
{
if ($this->_compress_output === TRUE)
{
if (isset($_SERVER['HTTP_ACCEPT_ENCODING']) && strpos($_SERVER['HTTP_ACCEPT_ENCODING'], 'gzip') !== FALSE)
{
header('Content-Encoding: gzip');
header('Content-Length: '.strlen($output));
}
else
{
// User agent doesn't support gzip compression,
// so we'll have to decompress our cache
$output = gzinflate(substr($output, 10, -8));
}
}
echo $output;
log_message('info', 'Final output sent to browser');
log_message('debug', 'Total execution time: '.$elapsed);
return;
}
// --------------------------------------------------------------------
//这里就是将程序运行过程中采集的数据通过profiler生成报表,不过感觉profiler生成报表的方式不雅观,
//在代码中拼html的方式导致生成的报表样式很难看,其实我们自己拿到这些数据可以通过js制作一个好看的调试控制台
if ($this->enable_profiler === TRUE)
{
$CI->load->library('profiler');
if ( ! empty($this->_profiler_sections))
{
$CI->profiler->set_sections($this->_profiler_sections);
}
// If the output data contains closing