构建一个 Perl/CGI 投票系统

发表于:2007-05-25来源:作者:点击数: 标签:
以 CGI 驱动的格式使用加锁的 DBM 文件来保存客户端数据,而不用使用 DBMS 级别: 中级 Allan Peda 资深 程序员 , Interpublic Corporation, Department of Global Information Services 2005 年 6 月 16 日 许多 Web 部署的应用程序都是在精心设计的 数据库
以 CGI 驱动的格式使用加锁的 DBM 文件来保存客户端数据,而不用使用 DBMS

级别: 中级

Allan Peda
资深程序员, Interpublic Corporation, Department of Global Information Services
2005 年 6 月 16 日

许多 Web 部署的应用程序都是在精心设计的数据库驱动的服务器端开发框架中编写的,例如 PHP 和 Java™ servlet,但是对于一些简单的程序(例如,整个数据库要能够存放在 Web 服务器的 RAM 中)来说,使用加锁的 DMB 文件和 Perl MLDBM 模块可以很容易地实现数据持久性。本文将给出一个基于 Web 的投票系统的真实的例子,重点介绍如何利用最小的外部模块、如何舍弃基于客户机的 cookie 以及如何利用 CGI 属性的优点。

软件正日益变得更加复杂,这并不是什么秘密;我们也看到一些额外的层次被添加到系统中,以保持软件组件的模块化。最重要的结果是,这些系统现在更易于维护,而且可扩展性也更好;但是有时这些技术有过多的重复,会导致软件的过度设计。在另外一些情况下,开发开发人员宁愿选择一些过度复杂但却非常有名的技术,也不愿意集成一些简单但却不太熟悉的技术。

不管怎样,如果所有人都有一把锤子,那么每个问题看起来都不过像一颗钉子而已。

我最近被请求为一所大学的学生组织设计一个小程序,以统计选举票数。这是一个非常简单的项目,每周处理的学生请求不会超过 500 个;之后该程序会立即统计并发布结果。

由于这个项目对服务级别的要求很低,因此使用一个外部数据库来处理查询并没有什么好处。相反,使用脚本可以直接快速读写数据结构。不过,我仍然希望能够将一些经过良好设计的功能封装在一起,而不是采用一些像意大利面那样,将杂乱的代码拼装在一起。我希望可以采用一个经过仔细考虑的自成体系的设计,该设计将提供一些简化的部署。

CGI 的考虑:简单性与复杂性
Perl 看来是这个项目的首选语言 —— 在很多平台上似乎都受到支持,此外,在 Perl 的知识库 CPAN 中,还有很多方便的库可以使用。

对于底层架构来说,CGI(Common Gateway Interface)是第一种广泛用来扩展 Web 服务器从而提供交互内容的方法。开发人员通常会鼓吹一些新的标准,例如 JSP、.NET、mod_perl、PHP 和 ISAPI,这些技术也的确可以弥补 CGI 的一些不足。但是在这个项目中,我们只需要对几百个用户计算投票数,这样一个 CGI 脚本很难构成一个大型的应用程序,因为所有的投票信息都可以放到 Web 服务器的系统 RAM 中。在用户每次提交一个读写数据的请求时,这可以将要查询的整个表装入内存中。

还有,通过将逻辑数据分隔成 3 个不同的物理文件,可以实现填写选票、确认选票和统计结果的逻辑顺序;这样做可以最大限度地减少打开已加锁的文件。

如果一个事务在很偶然的情况下因为加锁的文件而失败了,那么这并不会产生实际的问题。不管一个事务是由于网络问题还是加锁文件而失败的,结果都是相同的:用户只需再等待一会儿即可,选票随后很有可能对其中的一次尝试进行统计。我们应该记住这种行为,然而,对于不同的应用程序来说,情况并非总是如此,因此可能无法处理并发事务。

对于这个项目来说,CGI 提供了以下几个优点:

  • 它不需要特殊的 Web 服务器增强机制。
  • 它不需要数据库引擎(在这个简单的例子中)。
  • 可以进行增量开发。
  • 以后可以使用诸如 mod_perl 之类的加速器进行升级。

然而,需要记住的是,由于平台的限制,CGI 程序(它们会创建一些新的进程)在 Win32 的系统上运行速度非常慢。此外,尽管 Apache Web 服务器已经可以在 Windows® 上运行得很好,但是它依然被认为是一个 Linux™/UNIX® 系统上的程序。参考资料 部分提供了有关在 Win32 系统上可以使用的其他(非 IIS) Web 服务器的信息,在最初的 National Center for Supercomputing Applications (NCSA)站点上,还提供了一份 CGI 规范的经典介绍。

功能设计的考虑
现在让我们立即开始考虑这个简单项目的主要问题:功能设计。

以下是我们的一些考虑。开始的时候,用户面前会出现一个屏幕,要求输入用户自己的电子邮件地址,并从一个 Web 表单中选择几个候选人。选中候选人后就可以提交他们,结果会记录在本地的一个预选票中。然后,会向提供的电子邮件发送一个电子邮件确认。在这种情况中,我们假设一个经过验证的电子邮件地址就足以建立用户的身份。

这样会出现多次投票的问题。从实践角度来说,我想我们没有什么办法限制一个用户使用多个电子邮件地址进行多次投票,但是我们可以对选票进行限制,只允许一个电子邮件帐号投一票。这个电子邮件的验证中包含一个链接,它指向原来的 CGI 脚本,这样就可以将该链接与本地 DBM 文件中保存的数据进行比较。如果两个记录匹配,那么这张选票就是有效的。如果这两个记录不能匹配,那么这张选票就不会被核实。相反,会生成一个新的电子邮件确认,其中包含数据库中的一条新验证记录。这将覆盖对应电子邮件地址的预选票项,从而有效地从头再次处理选票。

如果这两条记录可以匹配,那么投票者就可以确认预选票。现在,如果投票者改变了注意,那么他可以只返回 Web 表单,并输入一个新的预选票,替换原来的预选票。这种设计可以得到一个比较安全的系统;条件是每个投票的用户都有且只有一个可以接受的电子邮件帐号,这样就可以保证每个用户都不会投两次票。(稍后我会回到这个问题上。)

现在让我们开始详细介绍系统的细节。

细节: 哈希键值
在 Perl 中,可以使用哈希键值来创建联合数组,从而使我们能够动态开发复杂的数据结构。当您将这种特性与将这些(任意复杂的)数据结构保存在二进制 DBM 文件中的能力结合在一起使用时,就可以开发出一个小型的数据库系统。完成这些工作所缺少的组件可以由 MLDBMMLDBM::Sync 模块提供。

MLDBM 模块可以将复杂的 Perl 哈希键值无缝地保存在一个本地文件中。MLDBM::Sync 模块使得对这些文件进行安全加锁成为可能,它使用了 $sync->Lock$sync->ReadLock 方法。在加载或保存所需要的结构之后,再调用 UnLock() 方法来刷新 I/O 并释放变量。(关于这方面的更多信息,请参阅 Perl 文档中有关 MLDBM::Sync 模块的内容:man 3 MLDBM::Sync。)

从根本上来说,逻辑流程非常简单,如清单 1 所示。

清单 1. 逻辑流程伪码
clearcase/" target="_blank" >cccccc" border="1">

            1  unless( defined( $q->param( $vparm ) )){
            2      # Display initial voting stuff here
            3      # select a candidate
            4      $ballotBox->printForm( $q );
            5  } else {
            6      # if vote is tallied, do _not_ mail a ballot
            7      if( $castBallot->voteIsTallied( $q ) ){
            8           print "Your vote has already been recorded"
            9      } else {
            10          #
            11          # vote not tallied yet, check if we have a draft ballot on file
            12          # and move the draftBallot into the castBallot object
            13          #
            14          if( $draftBallot->exactMatch( $q ) ){
            15              # cast ballot
            16              print $q->h2('Thank you, your vote has been recorded.');
            17              # add the vote to the cast ballot db file
            18              $castBallot->tallyVote( $q );
            19              # sum up all votes
            20              $ballotBox->addVotes( $castBallot );
            21              $cc_msg->send();
            22          } elsif ( $draftBallot->voter_is_okay( $voter_email )){
            23              # Send e-mail to allow voter to confirm vote
            24              $mime_msg->send()
            25          } else {
            26              print 'Only University ballots are acceptable';
            27          }
            28      }
            29  }
            

在确定底层的条件流程之后,剩下的惟一任务就是构建适合以后使用的对象。正如我在前面介绍的那样,可以使用 tie'd 变量和 MLDBM 文家锁定来检索和更新所需的哈希数据结构。所使用的对象更像是一些精巧的数据结构,而不像是一些羽翼丰满的对象;在这些对象之间,数据是以某种并行方式处理的:选票也从最初的预选票转换成为最终的正式选票。

换言之,选票清单被用来构建一个 DraftBallot,而这个 DraftBallot 又被用来创建 CastBallotBallotBox 类。这样,对于主要的投票 CGI 程序,耦合性就是最小的。

从另外一方面来说,虽然我通常认为使用一些依赖于外部资源(例如文件)的构造函数不是一个好的实践方法(因为这样可能会引起失败,并导致一些不可预知的状态),但是在这种情况下,以这种方式实现的代码将更易于理解。由于 Perl 并不依赖于指针,所以没什么理由不利用这种简单性。

细节:电子邮件
允许用户从您的 Web 服务器上发送电子邮件是一个危险的举动,因为垃圾邮件可以利用您的主机来胡乱发送电子邮件。为了将这种威胁降至最低,脚本通常会检查要发送的电子邮件地址是否是一个可到达的地址。您可以通过修改 DraftBallot 类中的验证方法 voter_is_okay() 来加强这种限制,使其在进行验证时参考一个可接受的电子邮件地址。这样可能会要求用户在进行投票之前进行注册。

防止出现重复投票的其他方法包括搜集 IP 地址或在客户机上设置 cookie,但是我不想采用这种方法,因为很多学生可能会在校园中使用共享的公用终端。

SASL 身份验证
很多服务提供商都只允许转发那些来自使用 SASL 身份验证的客户机的邮件。SASL(Simple Authentication and Security Layer) 是用来对基于连接的协议添加身份验证支持的一种方法,其中有一种协议包含了一个标识用户身份的命令,并让该用户对服务器进行身份验证,从而对保护后续的协议交互进行协商。如果双方经过协商同意使用,那么就会在协议和连接之间插入一个安全层。

在使用 SASL 认证时,您有两个选择:可以将这个脚本指向一台可以转发使用正确证书的电子邮件的机器,或者使用 Perl Net::SMTP_auth and Authen::SASL 模块重新编写这个脚本,与外部 SMTP 服务器直接进行联系。(第二种方法的速度较慢。)

细节:不太安全的投票
调用 $castBallot->dumpHTMLentrys() 方法会回显一个详细的信息,指出谁投票给了谁。实际上,我要注释掉这个调用,在选举结束之后使用 Linux at 批处理命令来关闭 Web 服务器。

当服务器关闭之后,您可以注释掉这一部分,并重新启动 Web 服务器,将其临时设置为 只监听 localhost 的地址。然后,通过单击一个之前提交的链接,可以将完整的结果回显给所有用户,并且可以通过向一个专用的免费电子邮件帐号发送一个副本,来收集完整的结果。

注意,在这个例子下,每个选票都不会被统计两次。在那些确定需要保密的情况中,可以使用一个简短的 JavaScript 函数来隐藏结果。诚然,有些人可能希望完全采用匿名投票,但是由于俱乐部的选举通常都是通过举手表决的,因此这很难实现安全的投票。

在考虑这种工作流程模式时,我意识到使用基于 GET 的验证链接以及使用非加密验证链接的必要性,这样可以进行一些实验,读取这些链接,并基于指定的电子邮件地址和一些已知的验证链接来构建一些错误的确认投票。为了防止这种事情的发生,同时为了仍然能够通过非加密链接进行简单的调试,我决定在验证步骤中添加一、两项内容:为每个预选票添加一个惟一的标识符。

这个标识符是基于操作系统中正在执行的脚本的集成标识符(PID)的。为了让预测验证预选票的 URL 更加困难,我们可以再使用一个随机数。我之所以关心这个问题,是因为会有一些恶意的用户可能会对非常直观的 URL 模式进行破解,从而试图构建一些虚假的验证选票。这是代码的一部分,它不会直接转换为一个 mod_perl 版本,因为它要依赖于正在运行的 Perl 的 PID,以及另外一个随机数。如果这个表单是从一个重用的 mod_perl 实例中生成的,那么在两次调用之间,PID 可能并不需要改变。

然后,我又意识到能使这个链接更具迷惑性的方法是使用一个 MD5 生成的哈希值,从而有效地隐藏所有投票者的信息。这具有双向受益的优点:既可以使它很难被伪造,同时还维护了基于 mod_perl 的脚本的可移植能力。缺点是代码有些难以调试,因为需要对客户机与服务器之间交换的信息进行监视。

细节:文件布局
安装过程要求 Web 服务器上有三种类型的目录:

  • 一个可写的目录,用来保存用户提交的选票。
  • 一个位置,CGI 需要在这里运行。
  • 一个位置,用来保存静态数据(例如 CSS,logo 图像,以及包含更详细指令的文件)。

还要注意的是,这种权限可以进行修改,这样,Web 服务器就可以向这个目录中写入 DBM 文件的内容了。

清单 2 显示了在 Web 服务器上创建典型目录的过程。

清单 2. 在 Web 服务器上设置目录

            $ id   uid=500(allan) gid=500(allan) groups=10(wheel),48(apache),500(allan)
            $ sudo mkdir /var/www/db /var/www/javascript/ /var/www/css/
            $ sudo chmod 2775 /var/www/db
            $ sudo chmod 2755 /var/www/javascript/ /var/www/css/
            $ sudo chown apache.apache /var/www/db/
            

严格来说,只有 cgi-bin(/var/www/cgi-bin)和 DBM(/var/www/db)目录是绝对必需的,因为它们分别保存了脚本的可执行文件和投票数据。清单 1 中给出的文件布局是专用于 Linux 的,Web 服务器进程的用户和组名可能有所不同,但实质上都需要在文件系统的适当地方放上几个 Web 服务器可以访问的组件。在将支持文件复制到各自的目录中之后,要确保对 Web 服务器的配置文件(例如 httpd.conf)中的别名进行了正确更新。

在创建清单 2 中所给出的目录之后,将 ZIP 文件中展开的内容复制到您的系统的类似目录中。其中最重要的是,ballot、DraftBallot.pm、BallotBox.pm 和 CastBallot.pm 文件都需要位于 cgi-bin 目录中。我们只需要使用 3 个非标准的 Perl 模块;安装过程如清单 3 所示(更详细的信息,请参阅模块的 README 文件)。

清单 3. 安装 Perl 模块

            $ sudo perl -MCPAN -e 'install MLDBM'
            $ sudo perl -MCPAN -e 'install MLDBM::Sync'
            $ sudo perl -MCPAN -e 'install MIME::Lite'
            

细节: 静态 DNS 与动态 DNS
虽然我可以用一个静态 IP 地址在拥有已分配的域的站点中建立这种服务,但是我觉得动态 DNS 应该可以提供一些安全上的好处。通常,如果一个服务器没有静态 IP 地址,那么来自 Web 上的访问流量就不可能太大,动态 DNS 让我们可以在另外一个顶级域名之上临时建立一个可解析的机器名。这样我们就可以在 Internet 上快速出现,并快速消失,将遭受黑客攻击的风险降至最低。最好的方法是,这种服务是免费的。

还需要指出的是,将服务器配置为监听一个非标准的大一些的端口(例如 8000)是很明智的,因为很多 ISP 都阻塞了端口 80 上的连接请求。客户机(投票者)通常可以从一个知名的静态地址(例如学校提供的主页)上的链接重定向到投票服务器上。在投票完成之后,提供 Web 服务的服务器就可以从 Web 上完全消失了,无需关闭或重新配置这台服务器。其中并没有任何缺点可以影响到所引用的页面(这台服务器是由其他人进行管理的)。在一些对政策敏感的环境中,这种考虑尤其重要。(有关使用动态 DNS 的更详细内容,请参阅 参考资料 一节的内容。)

细节:GET 有害吗?
浏览器可以使用 GETPOST 方法将数据传递到所引用的页面中,从而对状态进行维护;也可以通过传递给服务器上的消息头中包含的 cookie 信息对状态进行维护。为了确认一张选票是从一个真实的人(至少是从一个有效的电子邮件帐号中)那里发出的,应该先将预选票发送到一个电子邮件地址进行确认。此外,cc: 或 bcc: 消息也可以在以后引用。正如我前面介绍的一样,实现这种功能的最直接的方法是向投票者发送一个 HTTP GET 结构化的链接。当然,有些作者会宣称用来更新记录的 GET 方法并不好用。但是在这种情况下,任何这之后单击某一个链接的用户都只会接收一条更新消息,并且可以从这条消息了解每个候选人的目前有效选票,因此,这是无害的。

其他可用的改进
在使用这个脚本时,还要考虑其他一些安全问题,我们也应该考虑这些问题。任何允许外部实体来输入数据的程序都容易受到恶意的攻击,例如缓冲区溢出和嵌入式控制字符。反之,使用专用的程序来读写本地 DBM 文件至少具有以下优点:在没有 SQL 后门的地方,是不可能存在 SQL 插入攻击的。

在您同意需要对到达的数据进行过滤之后,我要将变量 $CGI::DISABLE_UPLOADS$CGI::POST_MAX 设置为非常严格的值。另外我建议采用如下设置:

  • 将传入的所有意料之外字符的所有变量都转换成字符串,并将其截断为合理的限制长度。
  • 脚本中保存了很多运行时数据。这样做的优点是不需要部署很多的文件,并设置它们的权限。缺点是用户可能不想编辑代码,代码变得更加不够清晰。一种折衷的方法是利用诸如 DATA 伪文件句柄之类的不完善系统在脚本的末尾保存数据。
  • 文件加锁是一个非常棘手的问题,很多时候都存在竞争条件。看起来我所找到的任何一种所谓的文件加锁的正确指南,之后又都进行了更新。我试图最大限度地缩短打开文件的时间,并充分利用为 MLDBM 模块提供的机制。
  • Perl 模块并没有放到 CGI 之外自己的路径中,因此从理论上来说,我们只能在 cgi-bin 目录中执行它。建议我们不要将这些模块设置为可执行的。
  • PHP 是 Linux 平台上广泛存在的一种工具,因此如果需要重新实现这个系统,我考虑将这个脚本移植到 PHP 中。然而,我不确定是否有一个与 MLDBM 模块等效的 PHP 模块。
  • 有些人认为投票表单的布局不合理,因为第一个候选人是默认值。
  • 我没有使用 perldoc,我本来应该使用它的。

结束语
假如有机会构建一个这样的系统,同时试着保持它的简单性并使其自成一体,那么该系统可以使我能够研究一些非常有用的 Perl 模块。我发现为这样一个简单的项目定义特性和开发功能规范的过程既很有趣又是一种享受。我希望本文中在构建这种系统时的一些考虑事项可以为您实现类似的项目提供一些帮助。

原文转自:http://www.ltesting.net