第3章 正则表达式基础与应用2

正文开始

3.5.3 转义在数据安全中的应用

数据安全是任何一款软件设计中都需要考虑的问题。从技术层面讲,数据安全就是保护存储和使用的数据不被窃听、盗取和破坏。这可能是由外部因素造成的,比如由于过滤不严格造成SQL注入漏洞、提升脚本执行权限等,也有可能是由代码内部设计造成的,如死循环、低效率的语句造成服务器性能下降以致影响访问。

社会学意义上的数据安全则更广泛。比如,在在线购物商城的设计中,由于设计者错误地使用自增ID作为商品的单据流水号,竞争对手或有心人很容易分析出这个商城的每日销售量,进而估算出销售额、利润等商业机密数据。

在程序中要保证数据的安全,除了要保证代码内部运行的可靠外,最主要的就是严格处理外部数据,即秉持“一切输入输出都是不可靠的”理论,这就要求我们做好数据过滤和验证。PHP编程中最简单的过滤机制就是转义,即对用户的输入和输出进行转义和过滤。图3-4所示是简单的留言本。

简单留言本的演示代码如下:

〈form action="" method="POST"〉

〈textarea name="content" 〉〈/textarea〉

〈input type="submit" value="留言" /〉

〈?php

echo $_POST['content'];

?〉

程序中没有任何的输入输出过滤,当在留言框中输入以下内容时得到如图3-5所示的页面。


 

〈style〉

Body{background:#000000}

〈/style〉

很显然,由于输入含有CSS代码和JavaScript代码,没有对其进行处理便原样输出,导致页面被篡改。这就是常说的“XSS攻击”。

针对上面的情况,只需在接收数据时使用htmlspecialchars函数,把代码中的特殊字符转为HTML实体,这样在输出时就不会使页面受影响了。这些特殊字符主要是“"”、“'”、“&”、“〈”、“〉”。比如把“〈script〉alert(1);〈/script〉”转为“<script>alert(1);</script>”,就可以阻止大部分XSS攻击。

注 HTML中规定字符实体引用(HTML Entities)还有对应的数字型实体(NCR),实际上是对这个实体的编号。比如,HTML Entities的格式“<”,NCR的格式“<”或“<”,均表示“〈”字符。

有的时候,不希望出现这些无意义的字符,因为既然页面不允许这些HTML标签,那就干脆过滤掉,而不是显示出来,这样页面就不会留下恶意攻击者所留下的这些代码。要达到这个目的,就需要借助正则表达式。比如,要过滤所有HTML标签的正则表达式如下所示:

〈\/?[^〉]+〉// 过滤所有HTML标签

这个正则表达式匹配嵌套的尖括号,一个“\/?”表示斜杠可有可无,这样就匹配标签的起始和关闭位置。“[^〉]+”意思是:不是右尖括号的字符重复一次和更多次。为什么不是“?”呢?因为HTML标签里至少也是一个字符,如〈b〉,而〈〉不是合法HTML标签,最后关闭右尖括号。用下面字符测试:

〈strong〉strong〈/strong〉

〈img src=a.jpg /〉

其匹配情况如图3-6所示。


 

从图中可以看出,运行结果基本符合我们的需求。同理,还可以只保留部分HTML标签,既不造成安全问题,又能使页面内容更丰富,这就可以利用UBB代码功能来实现。

前面提到过用正则表达式实现UBB标签的功能比较麻烦,但在这里只需要白名单功能,即只保留比较“安全”的标签,通过PHP内置的strip_tags函数可以容易做到。这个函数用于从字符串中去除HTML和PHP标记,仅保留参数中指定的标签。演示代码如下所示:

〈?php

$text = '〈p〉Test paragraph.〈/p〉〈!-- Comment --〉 〈a href="#fragment"〉Other text〈/a〉';

echo strip_tags($text);

echo "\n";

// 允许 〈p〉 和 〈a〉

echo strip_tags($text, '〈p〉〈a〉');

?〉

另外,在SQL语句构造中,当字符含有引号的时候,可能造成SQL解析和执行失败。这样就要求转义。一种处理办法是把引号转义成实体,另一种处理办法是使用addslashes函数把其转义。我们通常会使用后者。

addslashes函数返回一个字符串,该字符串为了数据库查询语句等的需要在某些字符前加上了反斜线。这些字符是单引号(')、双引号(")、反斜线(\)与NUL(NULL字符)。

注意 为了处理更多情况,还需要特别注意“%”、“'”等特殊字符。如果能转义则进行转义,没有对应的转义时,则进行过滤。

 3.5.4 URL重写与搜索引擎优化
 

    URL重写(Rewrite)是截取传入Web请求并自动将请求重定向到其他URL的过程。比如浏览器发来请求hostname/list_1,服务器自动将这个请求中定向为http://hostname/list.php?id=1。该技术常用于搜索引擎优化(Search Engine Optimization,SEO)。

    伪静态也是重写的一种实现。例如,某论坛网址为forum-17-1.html,实际地址可能是forum.php?fid=17&page=1,其中第一个数字是版块ID,第二个数字是页数。这样的网址看起来比较短,没有一大堆的&符号和GET参数,不但用户喜欢这种友好的网址,搜索引擎也喜欢这种简洁明了的网址。

    URL重写有两种实现方式:

    纯代码实现,通过解析PATH_INFO实现。

    服务器实现,如利用Apache的mod_rewrite模块实现。

    下面先介绍利用mod_rewrite实现重写的方法。例如,实现列表页面list.php?Mode=A重写为伪静态list-A.html,步骤如下(假设工程的根目录是E:/dev/www/php/new)。

    首先,配置Apache。在httpd.conf里把以下所示的这一行代码前面的#去掉,启用Rewrite规则。 

    #LoadModule rewrite_module modules/mod_rewrite.so

    在对应的〈Directory "E:/dev/www/php"〉配置项下设置AllowOverride All。

    在网站E:/dev/www/php/new目录下新建.htaccess文件,并输入如下内容:

    RewriteEngine on

    RewriteRule index.html index.php

    RewriteRule list-([A-Z]+)\.html$ list.php?mode=$1 [NC]

    重启Apache。现在访问http://127.1/list-A.html看看效果吧。可以看到访问http://127.1/list-A.html和http://127.1/list.php?mode=A指向的是同一个页面,这就说明伪静态设置成功了。

    现在逐条解释其原理。

    Apache开启Rewrite模块,该模块默认是关闭的。

    设置AllowOverride All的目的是让.htaccess文件生效;如果把AllowOverride设置成none,.htaccess文件将被完全忽略。

    新建一个.htaccess文件。.htaccess文件是Apache中重要的配置文件,其格式为纯文本。它提供针对目录改变配置的方法,通过在一个特定的文档目录中放置包含一个或多个指令的文件,作用于此目录及其所有子目录。

    注意 Windows资源管理器里不允许建立.htaccess文件,可以在命令行窗口输入“echo〉.htaccess”达到新建的目的。

    以下是对.htaccess中每一行代码的解释。

    1)打开运行时重写功能,其默认是关闭:

    RewriteEngine on
    2)建立一条重写规则,把index.php重写为index.html:

    RewriteRule index.html index.php

    很显然,这是一个伪静态,而“index.html index.php”是最简单的正则表达式。

    3)把原地址list.php?mode=$1形式重写成list-([A-Z]+)\.html$:

    RewriteRule list-([A-Z]+)\.html$ list.php?mode=$1 [NC]

    实际上上述代码可以理解为,如果Apache遇到符合正则表达式list-([A-Z]+)\.html$的请求,先捕获第一个括号里的值(这是紧挨着list-后面,以.html结尾的一个字母),那么就重定向到真实的请求地址list.php?mode=$1,而$1是正则表达式里的反向引用。这就是重写的语意。括号中的“NC”表示大小写不敏感。

    如果这个页面还有分页应该怎么写?比如list.php?mode=A&page=2。为了让URL更有意义,可以把这个地址重写为list-A-page-2.html。照葫芦画瓢,在已有基础上添加一条规则:

    RewriteRule list-([A-Z]+)-page-(\d?)\.html$ list.php?mode=$1&page=$2
    现在直接访问list-A-page-2.html试试。由于RewriteEngine为on,所以这一步并不需要重启Apache。虽然现在可以访问了,但是不是觉得这么写很累赘?

    第二条和第三条重写规则都是针对list.php文件,可以合并成一条吗?答案是可以的。完整.htaccess文件如下:

    RewriteEngine on

    RewriteRule index.html index.php

    #RewriteRule list-([A-Z]+)\.html$ list.php?mode=$1 [NC]这一行是注释

    RewriteRule list-([A-Z]+)(?:-page-)?(\d?)\.html$ list.php?mode=$1&page=$2 [NC]

    新的重写规则就是最常用的正则表达式,如“?:”表示非捕获性匹配。只要理解了正则表达式,再辅之以Apache手册,掌握URL重写就会成为一件很轻松地事情。

    提示 Nginx也能实现网址静态化,而且语法上几乎没什么差别,用到的都是基于正则表达式的语法。

    使用URL重写能给网站带来哪些好处呢?

    1)有利于搜索引擎的抓取。因为现在大部分搜索引擎更喜欢抓取一些静态页面。而现在页面大部分数据都是动态显示的。这就需要把动态页面变成静态页面,这样有利于搜索引擎抓取。

    2)用户更容易记忆。很少有用户关心网站页面地址,但对大中型网站来说,增强可读性还是必需的。

    3)隐藏实现技术。避免暴露采用的技术,给攻击网站增加阻碍。特别是前面讲的攻击方式,把参数隐藏起来,在一定程度增加了攻击的难度。

    重写还能帮助我们完成很多事情,比如简单下载处理。例如,现在需要对外提供文件下载服务,比如下载php.rar文件。在下载前还需要做许多额外工作,如下载权限的判断、服务器分流等,这就不是一个简单文件链接可以处理的,需要服务器端脚本做一些复杂处理。通过使用URL重写,可以把对php.rar文件的请求重定向到对一个PHP文件的请求,在这个PHP文件中进行判断,满足所有判断后再输出文件。假定当前工程目录是E:\www\php\download,现在要把对E:\www\php\download\php.rar的请求定向到一个PHP文件。添加下面的代码到当前目录的.htaccess文件中:

    RewriteEngine on

    RewriteRule (.*\.(rar| zip| chm))$ down.php?file=$1 [NC]

    down.php中的代码如下:

    〈?php// echo "The file you wanna download is\t",$_GET['file'];

    $filename=basename($_GET['file']);

    // 其他逻辑处理,如检查是否有权限或者是否属于盗链,处理完成后提供下载

    if (! is_file($filename) || ! is_readable($filename)) {

    exit("没有找到指定的文件");

    }

    header("Content-type: application/octet-stream");

    $ua = $_SERVER["HTTP_USER_AGENT"];

    // 处理中文文件名

    $encode_filename = urlencode($filename);

    $encode_filename = str_replace("+", "%20", $filename);

    if (preg_match("/MSIE/", $ua)) {

    header('Content-Disposition: attachment; filename="' .$encode_filename .'"');

    } else if (preg_match("/Firefox/", $ua)) {

    header("Content-Disposition: attachment; filename*=\"utf8''" .$filename .'"');

    } else {

    header('Content-Disposition: attachment; filename="' .$filename .'"');

    }

    // 如果服务器支持X_sendfile moudle,则使用X_sendfile头加速下载

    $x_sendfile_supported = in_array('mod_xsendfile', apache_get_modules());

    if (! headers_sent() && $x_sendfile_supported) {

    header("X-Sendfile: {$filename}");

    } else {

    @readfile($filename);

    }

    再浏览http://download.com/hello.rar(已将虚拟主机域download.com映射到E:\www\php\download目录),此请求将被重定向到down.php?file=hello.rar,这样就简单的实现了对文件下载的处理。

    我们还能对重写做进一步扩充,来满足不同的需求。更多URL重写知识可以参考Apache 2手册。

    3.5.5 删除文件中的空行和注释
    

    不仅在代码中会用到正则表达式,其实在日常软件应用中也会涉及正则表达式。比如字处理软件、代码开发工具中都提供对正则表达式查找和替换的支持。

    这里以UltraEdit为例来介绍正则表达式在日常软件中的应用。UltraEdit是一款功能强大的编辑器,支持正则表达式的使用。UltraEdit虽然和IDE无法相提并论,但是在处理一些小文件时,会显出其快速、轻量级的特点。

    例如,PHP源文件中包含空行和注释,UltraEdit中的代码如图3-7所示。

    这里面许多空行和注释,为了提高代码的可读性,需要去除大段空行。如果手工操作,必然很麻烦。此时,可以使用UltraEdit的正则表达式功能。在编辑菜单中,选择“替换”,输入如下表达式:

    %[ ^t]++^p

    注意,^t前面的空格也要输入。单击替换所有,文件中的空行就删除了。如果还要删除注释,可以输入“//?*$”,处理完成后的效果如图3-8所示。


     

    这里使用UltraEdit的正则表达式,也可以选择UNIX(POSIX规范)和Perl(PCRE规范)风格的表达式,它们之间略有不同。

    提示 有些框架为了尽力提升效率或者由于商业的原因,往往会在部署和发布时,通过解析PHP代码中的token清除源文件中表示空白和注释的token。在这种情况下,使用代码的方式可能更好。

    但有时无法使用代码完成这件事,我们不得不使用正则表达式。比如在使用Word保存资料的时候,文件中常常会带有大量的空白段落,通常只能手动删除这些空段落来调整格式,费时费力。在Word中,选择特殊字符,把^p^p替换成^p即可。Word中这两个所谓的“特殊字符”,实际上就是正则表达式的一种体现。

3.6 正则表达式的效率与优化


    正则表达式可以看做描述字符串匹配的算法代码,本质上说是一种有限状态机在计算机中的表示方法。状态机,表示有限个状态以及在这些状态之间进行转移和动作等行为的数学模型,在计算机中表示出来的就是有向图。而作为图就会涉及查找、回溯过程。不同查找的方式,其回溯过程也不一样,效率自然也是有区别的。要想弄明白正则表达式的效率,就得深入编译原理、数据结构等概念,这里不做原理性阐述,只介绍一些普遍原则。

    注意 不同语言中有不同实现和限制,因此下面一些原则只是最基本的原则,不保证在所有实现中通用。有时候,某一种实现会对预知的情况进行优化,而另一种则不会。也就是说,正则表达式的效率不仅和正则引擎的种类有关,还和引擎具体实现有关。

    1)使用字符组代替分支条件。比如,使用[a-d]表示a~d之间的字母,而不是使用(a|b|c|d)。下面代码说明二者的效率差异。

    〈?php

    $cnt=1000;

    $testStr="";

    for($i=0;$i〈1000;$i++){

    $testStr.="abababcdefg";

    }

    // 第一种方案

    $start=microtime(TRUE);

    for($i=0;$i〈$cnt;$i++){

    preg_match('#^(a|b|c|d|e|f|g)+$#',$testStr);

    }

    echo 'waste time(s):',microtime(TRUE)-$start,PHP_EOL;

    // 第二种方案

    $start=microtime(TRUE);

    for($i=0;$i〈$cnt;$i++){

    preg_match('#^[a-g]+$#',$testStr);

    }

    echo 'waste time(s):',microtime(TRUE)-$start,PHP_EOL;

    // 第三种方案和第二种方案本质上是相同的

    $start=microtime(TRUE);

    for($i=0;$i〈$cnt;$i++){

    preg_match('#^[abcdefg]+$#',$testStr);

    }

    echo 'waste time(s):',microtime(TRUE)-$start,PHP_EOL;
    运行结果如下所示:

    waste time(s):3.2742960453033

    waste time(s):0.059613943099976

    waste time(s):0.059823036193848

    可以看出,[a-g]和[abcdefg]这两种表达式的效率相当,且使用字符组比分支条件的速度要快很多。这是由于在匹配单个字符的时候,引擎会把[abc]这样的字符组视为1个元素,而不是3个元素(a、b、c)。整个元素作为匹配迭代的一个单元,不需要进行三次迭代,从而提高匹配效率。

    2)优先选择最左端的匹配结果。这在介绍分支条件匹配邮编的时候已经提到过。对于传统型NFA引擎来说,这样改动对正则匹配的效率是有利的,因为引擎一旦找到匹配结果就会停下来,而不会去尝试正则表达式的每一种可能(PHP中的preg函数就属于传统型NFA引擎)。

    3)标准量词是匹配优先的。若用量词约束某个表达式,那么在匹配成功前,进行的尝试次数有下限和上限。例如,正则表达式为:

    \w*(\d+)

    字符串为copy2003y。这个正则匹配的$1是多少?如果回答2003就错了,其结果应该是3。解释如下:当正则引擎用“\w*(\d+)”匹配字符串copy2003y时,会先用“\w*”匹配字符串copy2003y。“\w*”会匹配字符串copy2003y的所有字符,然后再交给“\d+”匹配剩下的字符串,而剩下的没有了。这时,“\w*”规则会不情愿地吐出一个字符,给“\d+”匹配。同时,在吐出字符之前,记录一个点。这个点就是用于回溯的点,然后“\d+”匹配y,发现不能匹配成功,此时会要求“\w*”再吐出一个字符;“\w*”先记录一个回溯的点,再吐出一个字符。这时,“\w*”匹配结果只有copy200,已经吐出3y。“\d+”再去匹配3,发现匹配成功,会通知引擎,并且直接显示出来。所以,“(\d+)”的结果是3,而不是2003。

    如果改为非贪婪模式呢?“\w*?(\d+)”匹配结果就应该是2003。由于“\w*?”是非贪婪,正则引擎会用表达式“\w+?”每次仅匹配一个字符串,然后再将控制权交给后面的“\d+”匹配下一个字符,同时记录一个点,用于匹配不成功时,返回这里再次匹配。

    提示 尽量以组为单位进行匹配,使用固化分组就能避免无休止的匹配。

    4)谨慎用点号元字符,尽可能不用星号和加号这样的任意量词。只要能确定范围(例如“\w”),就不要用点号;只要能够预测重复次数,就不要用任意量词。假设一条微博消息的XML正文部分结构如下:

    〈span class="msg"〉…〈/span〉

    正文中无尖括号,写法如下:

    〈span class="msg"〉[^〈]{1,200}〈/span〉

    或者:

    〈span class="msg"〉.*〈/span〉,

    上述第一种代码的思路要好于第二种代码,原因有两个:

    使用“[^〈]”,保证了文本的范围不会超出下一个小于号所在位置。

    明确长度范围{1,200},依据是一条微博消息大致的字符长度范围是固定的,现在微博字数长度限制是140个字。

    PHP的PCRE扩展中提供了两个设置项:

    pcre.backtrack_limit//最大回溯数

    pcre.recursion_limit//最大嵌套数

    默认backtarck_limit是100000(10万),recursion_limit限制最大正则嵌套层数。在正则表达式的使用中,应尽量避免回溯次数过多等情况。

    因回溯次数过多导致正则匹配失败的案例如下:

    〈?php

    $a=range(1,12636);

    shuffle($a);

    $d=print_r($a,TRUE);

    echo strlen($d)/1024,PHP_EOL;

    $a = "〈?xml version='1.0' encoding='iso-8859-1' ?〉〈ppc〉header".$d."tail〈/ppc〉";

    preg_match_all("/〈ppc〉(.*?)(\d*)〈\/ppc〉/s", $a, $m, PREG_SET_ORDER);

    var_dump($m);

    echo 'had result:',PHP_EOL;

    echo

    strlen($m[0][1]),PHP_EOL,substr($m[0][1],0,50),PHP_EOL,substr($m[0][1],-50),

    PHP_EOL;

    // 复制上面的代码,唯一的修改是增加字符串长度,使正则匹配爆栈

    // ini_set('pcre.backtrack_limit',100000000);// 这里增加为1亿

    $x2=range(1,12638);

    shuffle($x2);

    $d2=print_r($x2,TRUE);

    echo strlen($d2)/1024,PHP_EOL;

    $a2 = "〈?xml version='1.0' encoding='iso-8859-1' ?〉〈ppc〉header".$d2."tail〈/ppc〉";

    $ret = preg_match_all("/〈ppc〉(.*?)(\d*)〈\/ppc〉/s", $a2, $m2, PREG_SET_ORDER);

    var_dump($m);

    echo preg_last_error();

    echo 'had no result:',PHP_EOL;

    echo

    strlen($m2[0][1]),PHP_EOL,substr($m2[0][1],0,50),PHP_EOL,substr($m2[0][1],-50);

    注意 程序的运行结果依赖于你的PHP的设置。

    这个案例告诉我们,由于正则表达式使用不当导致匹配失败的情况是有可能发生的,特别是当Web文档比较大、结构比较复杂时。解决办法就是,把以下代码前面的注释符去除,给PHP配置更大的回溯栈空间:

    ini_set('pcre.backtrack_limit',100000000);

    但这是治标不治本的办法,最终解决方案还是优化正则表达式。

    同理,能用懒惰匹配就坚决不用贪婪匹配。

    5)尽量使用字符串函数处理代替。使用字符串函数和正则表达式都可以处理字符串,两者相比,字符串函数处理的效率更高。当然,有些情况几乎是非正则表达式不能胜任的,或者不用正则表达式的成本太高,这些情况不得不用正则表达式,既然如此,就应该设计好。

    6)合理使用括号。每使用一个普通括号(),而不是非捕获型括号(?:…),就会保留一部分内存等着再次访问。这样的正则表达式、无限次的运行次数,无异于一根根稻草的堆加,终将会把骆驼压死。

    7)起始、行描点优化。能确定起始位置,使用^能提高匹配的速度。同理,使用$标记结尾,正则引擎则会从符合条件的长度处开始匹配,略过目标字符串中许多可能的字符。在写正则表达式时,应该将描点独立出来,例如“^(?:abc|123)比^123|^abc”效率高,而“^(abc)比(^abc)”效率要高。

    这个原则不适用于所有正则引擎。比如在PCRE中,二者效率相当。

    8)量词等价转换的效率差异。例如在PHP中,使用“\d\d\d”和“\d{3}”,或者“====”和“={4}”,它们之间的效率几乎没有差别。但是换用其他语言可能就会有比较明显的性能差异了。

    9)对大而全的表达式进行拆分。

    10)使用正则以外的解决方案。前面已经提到,在有的场合可以使用字符串来代替正则表达式,此外,还有其他方案可以代替正则表达式。例如,在某项目中需要分析PHP代码,分离出对应的函数调用(以及源代码对应的位置)。虽然这使用正则表达式也可以实现,但无论从效率还是代码复杂度方面考虑,这都不是最优的方式。PHP已经内置解析器的接口PHP Tokenizer。

    使用PHP Tokenizer能简单、高效、准确地分析出PHP源代码的组成。token_get_all函数参数为一段PHP代码,可提取出这段代码里的常量、变量、类名、函数等。这在编写phpdoc、代码优化提速、自动加载类时都可以用到。比如,在解析URL时没必要用正则表达式,使用prase_url函数即可;在获取HTTP头时,也可以使用get_headers函数。

    在进行输入校验时,可以使用PHP 5提供的filter函数。例如,校验E-mail地址的代码如下:

    filter_var('admin@example.com', FILTER_VALIDATE_EMAIL);

    这样是不是好多了呢?如果在JavaScript里,可以使用DOM代替一些正则匹配。

    这里总结几种正则表达式的取代方案,它们能部分取代正则表达式的实现。

    PHP的字符串函数;

    PHP的Tokenizer系列函数;

    PHP的url函数及一些http函数;

    PHP的filter系列函数;

    JavaScript的DOM模型。

3.7 本章小结


    本章中主要讲解了正则表达式的一些基本概念和语法,并且通过理论结合实际的方式,讲解了正则表达式在开发中的应用,最后介绍了正则表达式的效率优化和一些替代方案。

    正则表达式中最容易混淆的就是转义,最难理解的就是环视,而环视在复杂的正则匹配中又是无法避免的,所以重点要掌握环视的概念和应用。

    在实例讲解中以XSS攻击为例,强调数据过滤的必要性,“一切输入都是不可信任的”,在设计中,要始终保持高度警惕。当然,正则表达式只是一种解决方案。接下来着重讲了SEO中的静态化,通过Apache的URL重写把动态请求地址映射到一个静态HTML文件上,从而实现对搜索引擎友好的地址。

    正则优化的关键理念就是“减少回溯”,常见的手段就是减少分支、使用环视和懒惰匹配。最后列举几种正则表达式的替代方案,供读者参考。

    正则表达式是一种抽象语法,要熟练掌握正则表达式,不但需要学会总结,还需要多多练习。

 

正文结束

PHP接口(interface)和抽象类(abstract) php sku库存算法服务器版本