第十九章 安全

优质
小牛编辑
141浏览
2023-12-01

Internet 并不安全。

现如今,每天都会出现新的安全问题。我们目睹过病毒飞速地蔓延,大量被控制的肉鸡作为武器来攻击其他人,与垃圾邮件的永无止境的军备竞赛,以及许许多多站点被黑的报告。

作为 web 开发人员,我们有责任来对抗这些黑暗的力量。每一个 web 开发者都应该把安全看成是 web 编程中的基础部分。不幸的是,要实现安全是困难的。攻击者只需要找到一个微小的薄弱环节,而防守方却要保护得面面俱到。

Django 试图减轻这种难度。它被设计为自动帮你避免一些 web 开发新手(甚至是老手)经常会犯的错误。尽管如此,需要弄清楚,Django 如何保护我们,以及我们可以采取哪些重要的方法来使得我们的代码更加安全。

首先,一个重要的前提:我们并不打算给出 web 安全的一个详尽的说明,因此我们也不会详细地解释每一个薄弱环节。在这里,我们会给出 Django 所面临的安全问题的一个大概。

Web 安全现状

如果你从这章中只学到了一件事情,那么它会是:在任何条件下都不要相信浏览器端提交的数据。

你从不会知道 HTTP 连接的另一端会是谁。可能是一个正常的用户,但是同样可能是一个寻找漏洞的邪恶的骇客。

从浏览器传过来的任何性质的数据,都需要近乎狂热地接受检查。这包括用户数据(比如 web表单提交的内容)和带外数据(比如,HTTP 头、cookies 以及其他信息)。要修改那些浏览器自动添加的元数据,是一件很容易的事。

在这一章所提到的所有的安全隐患都直接源自对传入数据的信任,并且在使用前不加处理。你需要不断地问自己,这些数据从何而来。

SQL 注入

SQL 注入 是一个很常见的形式,在 SQL 注入中,攻击者改变 web 网页的参数(例如 GET /POST数据或者 URL 地址),加入一些其他的 SQL 片段。未加处理的网站会将这些信息在后台数据库直接运行。这也许是最危险的一种,然而不幸的是,也是最多的一种隐患。

这种危险通常在由用户输入构造 SQL 语句时产生。例如,假设我们要写一个函数,用来从通信录搜索页面收集一系列的联系信息。为防止垃圾邮件发送器阅读系统中的 email,我们将在提供 email 地址以前,首先强制用户输入用户名。

def user_contacts(request):

user = request.GET['username']

sql = "SELECT * FROM user_contacts WHERE username = '%s';" % username

# execute the SQL here...

备注

在这个例子中,以及在以下所有的“不要这样做”的例子里,我们都去除了大量的代码,避免这些函数可以正常工作。我们可不想这些例子被拿出去使用。

尽管,一眼看上去,这一点都不危险,实际上却不尽然。

首先,我们对于保护 email 列表所采取的措施,遇到精心构造的查询语句就会失效。想象一下,如果攻击者在查询框中输入 "' OR 'a'='a" 。此时,查询的字符串会构造如下:

SELECT * FROM user_contacts WHERE username = '' OR 'a' = 'a';

由于我们允许不安全的 SQL 语句出现在字符串中,攻击者加入 OR 子句,使得每一行数据都被返回。

事实上,这是最温和的攻击方式。如果攻击者提交了 "'; DELETE FROM user_contacts WHERE 'a' = 'a'" ,我们最终将得到这样的查询:

SELECT * FROM user_contacts WHERE username = ''; DELETE FROM user_contacts WHERE 'a'

= 'a';

哦!我们整个通信录名单去哪儿了?

解决方案

尽管这个问题很阴险,并且有时很难发现,解决方法却很简单: 绝不信任用户提交的数据,并且在传递给 SQL 语句时,总是转义它。

Django 的数据库 API 帮你做了。它会根据你所使用的数据库服务器(例如 PostSQL 或者 MySQL)的转换规则,自动转义特殊的 SQL 参数。

举个例子,在下面这个 API 调用中: foo.get_list(bar__exact="' OR 1=1")

Django 会自动进行转义,得到如下表达: SELECT * FROM foos WHERE bar = '\' OR 1=1'

完全无害。

这被运用到了整个 Django 的数据库 API 中,只有一些例外:

  • 传给 extra() 方法的 where 参数(参见附录 C)。这个参数接受原始的 SQL 语句。

  • 使用底层数据库 API 的查询。

    以上列举的每一个示例都能够很容易的让您的应用得到保护。在每一个示例中,为了避免字符串被篡改而使用 绑定参数 来代替。也就是说,在本章中我们使用到的所有示例都应该写成如下所示:

    from django.db import connection def user_contacts(request):

    user = request.GET['username']

    sql = "SELECT * FROM user_contacts WHERE username = %s;" cursor = connection.cursor()

    cursor.execute(sql, [user])

    # ... do something with the results

    底层 execute 方法采用了一个 SQL 字符串作为其第二个参数,这个 SQL 字符串包含若干’%s’占位符,execute 方法能够自动对传入列表中的参数进行转义和插入。

    不幸的是,您并不是在 SQL 中能够处处都使用绑定参数,绑定参数不能够作为标识符(如表或列名等)。因此,如果您需要这样做—我是说—动态构建 POST 变量中的数据库表的列表的话,您需要在您的代码中来对这些数据库表的名字进行转义。Django 提供了一个函数, django.db.backend.quote_name ,这个函数能够根据当前数据库引用结构对这些标识符进行转义。

    跨站点脚本 (XSS)

    在 Web 应用中, 跨站点脚本 (XSS)有时在被渲染成 HTML 之前,不能恰当地对用户提交的内容进行转义。这使得攻击者能够向你的网站页面插入通常以 <script> 标签形式的任意 HTML代码。

    攻击者通常利用 XSS 攻击来窃取 cookie 和会话信息,或者诱骗用户将其私密信息透漏给被人

    (又称 钓鱼 )。

    这种类型的攻击能够采用多种不同的方式,并且拥有几乎无限的变体,因此我们还是只关注某个典型的例子吧。让我们来想想这样一个极度简单的 Hello World 视图:

    def say_hello(request):

    name = request.GET.get('name', 'world')

    return render_to_response("hello.html", {"name" : name})

    这个视图只是简单的从 GET 参数中读取姓名然后将姓名传递给 hello.html 模板。我们可能会为这个视图编写如下所示的模板:

    <h1>Hello, {{ name }}!</h1>

    因此,如果我们访问 http://example.com/hello/?name=Jacob ,被呈现的页面将会包含一以下这些:

    <h1>Hello, Jacob!</h1>

    但是,等等,如果我们访问 http://example.com/hello/?name=<i>Jacob</i> 时又会发生什么呢?然后我们会得到:

    <h1>Hello, <i>Jacob</i>!</h1>

    当然,一个攻击者不会使用<i>标签开始的类似代码,他可能会用任意内容去包含一个完整的

    HTML 集来劫持您的页面。这种类型的攻击已经运用于虚假银行站点以诱骗用户输入个人信息,事实上这就是一种劫持 XSS 的形式,用以使用户向攻击者提供他 们的银行帐户信息。

    如果您将这些数据保存在数据库中,然后将其显示在您的站点上,那么问题就变得更严重了。例如,一旦 MySpace 被发现这样的特点而能够轻易的被 XSS 攻击,后果不堪设想。某个用户向他的简介中插入 JavaScript,使得您在访问他的简介页面时自 动将其加为您的好友,这样在几天之内,这个人就能拥有上百万的好友。

    现在,这种后果听起来还不那么恶劣,但是您要清楚——这个攻击者正设法将 他 的代码而不是 MySpace 的代码运行在 您 的计算机上。这显然违背了假定信任——所有运行在 MySpace上的代码应该都是 MySpace 编写的,而事实上却不如此。

    MySpace 是极度幸运的,因为这些恶意代码并没有自动删除访问者的帐户,没有修改他们的密码,也并没有使整个站点一团糟,或者出现其他因为这个弱点而导致的其他噩梦。

    解决方案

    解决方案是简单的:总是转义可能来自某个用户的任何内容。如果我们像如下代码来简单的重写我们的模板:

    <h1>Hello, {{ name|escape }}!</h1>

    这样一来就不总是那么的弱不禁风了。在您的站点上显示用户提交的内容时,您应该总是使用 escape 标签(或其他类似的东西)。

    为什么 Django 没有为您完成这些呢?

    在 Django 开发者邮件列表中,将 Django 修改成为能够自动转义在模板中显示的所有变量是一个老话题了。

    迄今为止,Django 模板都避免这种行为,因为这样就略微改变了 Django 应该相对直接的行为(展现变量)。这是一个棘手的问题,在评估上的一种艰难折中。增加隐藏隐式行为违反了 Django 的核心理念(对于 Pythons 也是如此),但是安全性是同等的重要。

    所有这一切都表明,在将来某个适当的时机,Django 会开发出某些形式的自动转义(或者很大程度上的自动转义)。在 Django 特性最新消息中查找正式官方文档是一个不错的主意,那里的东西总是要比本书中陈述的要更新的多,特别是打印版本。

    甚至,如果 Django 真的新增了这些特性,您也应该习惯性的问自己,一直以来,这些数据都 来自于哪里呢?没有哪个自动解决方案能够永远保护您的站点百分之百的不会受到 XSS 攻击。

    伪造跨站点请求

    伪造跨站点请求(CSRF)发生在当某个恶意 Web 站点诱骗用户不知不觉的从一个信任站点下载某个 URL 之时,这个信任站点已经被通过信任验证,因此恶意站点就利用了这个被信任状态。

    Django 拥有内建工具来防止这种攻击。这种攻击的介绍和该内建工具都在第 14 章中进行进一步的阐述。

    会话伪造/劫持

    这不是某个特定的攻击,而是对用户会话数据的通用类攻击。这种攻击可以采取多种形式:

    中间人 攻击:在这种攻击中攻击者在监听有线(或者无线)网络上的会话数据。

    伪造会话 :攻击者利用会话 ID(可能是通过中间人攻击来获得)将自己伪装成另一个用户。

    这两种攻击的一个例子可以是在一间咖啡店里的某个攻击者利用店的无线网络来捕获某个会话 cookie,然后她就可以利用那个 cookie 来假冒原始用户。

    伪造 cookie :就是指某个攻击者覆盖了在某个 cookie 中本应该是只读的数据。第 12 章详细地解释了 cookie 的工作原理,cookie 的一个显著特点就是浏览者和恶意用户想要背着您做些修改,是一件很稀松平常的事情。

    Web 站点以 IsLoggedIn=1 或者 LoggedInAsUser=jacob 这样的方式来保存 cookie 由来已久,使用这样的 cookie 是再简单不过的了。

    但是,从更加细微的层面来看,信任存储在 cookie 中的任何东西都从来不是一个好主意,因为您从来不知道多少人已经对它一清二楚。

    会话滞留 :攻击者诱骗用户设置或者重设置该用户的会话 ID。

    例如,PHP 允许在 URL(如 http://example.com/?PHPSESSID=fa90197ca25f6ab40bb1374c510d7a32 等)中传递会话标识符。攻击者诱骗用户点击某个带有硬编码会话 ID 的链接就会导致该用户恢复那个会话。

    会话滞留已经运用在钓鱼攻击中,以诱骗用户在攻击者拥有的账号里输入其个人信息,之后攻击者就能够登陆自己的帐户来获取被骗用户输入的数据。

    会话中毒 :攻击者通过用户提交设置会话数据的 Web 表单向该用户会话中注入潜在危险数据。

    一个经典的例子就是一个站点在某个 cookie 中存储了简单的用户偏好(比如一个页面背景颜色)。攻击者能够诱骗用户点击某个链接来提交某种颜色,而实际上链接中已经包含了某个

    XXS 攻击,如果这个颜色没有被转义,攻击者就可以继续向该用户环境中注入恶意代码。

    解决方案

    有许多基本准则能够保护您不受到这些攻击:不要在 URL 中包含任何 session 信息。

    Django 的 session 框架(见第 12 章)干脆不允许 URL 中包含 session。

    不要直接在 cookie 中存储数据,而是保存一个映射后台 session 数据的 session ID。

    如果使用 Django 内置的 session 框架(即 request.session ),它会自动进行处理。这个

    session 框架仅在 cookie 中存储一个 session ID,所有的 session 数据将会被存储在数据库中。

    如果需要在模板中显示 session 数据,要记得对其进行转义。可参考之前的 XSS 部分,对所有用户提交的数据和浏览器提交的数据进行转义。对于 session 信息,应该像用户提交的数据一样对其进行处理。

    任何可能的地方都要防止攻击者进行 session 欺骗。

    尽管去探测究竟是谁劫持了会话 ID 是几乎不可能的事儿,Django 还是内置了保护措施来抵御暴力会话 攻击。会话 ID 被存在哈希表里(取代了序列数字),这样就阻止了暴力攻击,并且如果一个用户去尝试一个不存在的会话那么她总是会得到一个新的会话 ID,这 样就阻止了会话滞留。

    请注意,以上没有一种准则和工具能够阻止中间人攻击。这些类型的攻击是几乎不可能被探测的。如果你的站点允许登陆用户去查看任意敏感数据的话,你应该 总是 通过 HTTPS 来提供网站服务。此外,如果你的站点使用SSL,你应该将 SESSION_COOKIE_SECURE 设置为 True ,这样就能够使 Django 只通过 HTTPS 发送会话 cookie。

    邮件头部注入

    邮件头部注入 :仅次于 SQL 注入,是一种通过劫持发送邮件的 Web 表单的攻击方式。攻击者能够利用这种技术来通过你的邮件服务器发送垃圾邮件。在这种攻击面前,任何方式的来自

    Web 表单数据的邮件头部构筑都是非常脆弱的。

    让我们看看在我们许多网站中发现的这种攻击的形式。通常这种攻击会向硬编码邮件地址发送一个消息,因此,第一眼看上去并不显得像面对垃圾邮件那么脆弱。

    但是,大多数表单都允许用户输入自己的邮件主题(同时还有 from 地址,邮件体,有时还有部分其他字段)。这个主题字段被用来构建邮件消息的主题头部。

    如果那个邮件头部在构建邮件信息时没有被转义,那么攻击者可以提交类似 "hello\ncc:spamvictim@example.com" (这里的 "\n" 是换行符)的东西。这有可能使得所构建的邮件头部变成:

    To: hardcoded@example.com Subject: hello

    cc: spamvictim@example.com

    就像 SQL 注入那样,如果我们信任了用户提供的主题行,那样同样也会允许他构建一个头部恶意集,他也就能够利用联系人表单来发送垃圾邮件。

    解决方案

    我们能够采用与阻止 SQL 注入相同的方式来阻止这种攻击:总是校验或者转义用户提交的内容。

    Django 内建邮件功能(在 django.core.mail 中)根本不允许在用来构建邮件头部的字段中存在换行符(表单,to 地址,还有主题)。如果您试图使用 django.core.mail.send_mail 来处理包含换行符的主题时,Django 将会抛出 BadHeaderError 异常。

    如果你没有使用 Django 内建邮件功能来发送邮件,那么你需要确保包含在邮件头部的换行符能够引发错误或者被去掉。你或许想仔细阅读 django.core.mail 中的 SateMIMEText 类来看看 Django 是如何做到这一点的。

    目录遍历

    目录遍历 :是另外一种注入方式的攻击,在这种攻击中,恶意用户诱骗文件系统代码对 Web服务器不应该访问的文件进行读取和/或写入操作。

    例子可以是这样的,某个视图试图在没有仔细对文件进行防毒处理的情况下从磁盘上读取文件:

    def dump_file(request):

    filename = request.GET["filename"]

    filename = os.path.join(BASE_PATH, filename) content = open(filename).read()

    # ...

    尽管一眼看上去,视图通过 BASE_PATH(通过使用 os.path.join )限制了对于文件的访问,但如果攻击者使用了包含 .. (两个句号,父目录的一种简写形式)的文件名,她就能够访问到 BASE_PATH 目录结构以上的文件。要获取权限,只是一个时间上的问题

    ( ../../../../../etc/passwd )。

    任何不做适当转义地读取文件操作,都可能导致这样的问题。允许 写 操作的视图同样容易发生问题,而且结果往往更加可怕。

    这个问题的另一种表现形式,出现在根据 URL 和其他的请求信息动态地加载模块。一个众所周知的例子来自于 Ruby on Rails。在 2006 年上半年之前,Rails 使用类似于

    http://example.com/person/poke/1 这样的 URL 直接加载模块和调用函数。结果是,精心构造的 URL,可以自动地调用任意的代码,包括数据库的清空脚本。

    解决方案

    如果你的代码需要根据用户的输入来读写文件,你就需要确保,攻击者不能访问你所禁止访问的目录。

    备注

    不用多说,你 永远 不要在可以让用户读取的文件位置上编写代码!

    Django 内置的静态内容视图是做转义的一个好的示例(在 django.views.static 中)。下面是相关的代码:

    import os import posixpath

    # ...

    path = posixpath.normpath(urllib.unquote(path)) newpath = ''

    for part in path.split('/'): if not part:

    # strip empty path components continue

    drive, part = os.path.splitdrive(part) head, part = os.path.split(part)

    if part in (os.curdir, os.pardir):

    # strip '.' and '..' in path continue

    newpath = os.path.join(newpath, part).replace('\\', '/')

    Django 不读取文件(除非你使用 static.serve 函数,但也受到了上面这段代码的保护),因此这种危险对于核心代码的影响就要小得多。

    更进一步,URLconf 抽象层的使用,意味着不经过你明确的指定,Django 决不会 装载代码。通过创建一个 URL 来让 Django 装载没有在 URLconf 中出现的东西,是不可能发生的。

    暴露错误消息

    在开发过程中,通过浏览器检查错误和跟踪异常是非常有用的。Django 提供了漂亮且详细的

    debug 信息,使得调试过程更加容易。

    然而,一旦在站点上线以后,这些消息仍然被显示,它们就可能暴露你的代码或者是配置文件内容给攻击者。

    还有,错误和调试消息对于最终用户而言是毫无用处的。Django 的理念是,站点的访问者永远不应该看到与应用相关的出错消息。如果你的代码抛出了一个没有处理的异常,网站访问者不应该看到调试信息或者 任何 代码片段或者 Python(面向开发者)出错消息。访问者应该只看到友好的无法访问的页面。

    当然,开发者需要在 debug 时看到调试信息。因此,框架就要将这些出错消息显示给受信任的网站开发者,而要向公众隐藏。

    解决方案

    Django 有一个简单的标志符,来控制这些出错信息显示与否。如果 DEBUG 被设置为 True ,错误消息就会显示在浏览器中。否则,Django 会返回一个 HTTP 500(内部服务器错误)的消息, 并显示你所提供的出错页面。这个错误的模板叫 500.html ,并且这个文件需要保存在你的某个模板目录的根目录中。

    由于开发者仍然需要在上线的站点上看到出错消息,这样的出错信息会向 ADMINS 设定选项自动发送 email。

    在 Apache 和 mod_python 下开发的人员,还要保证在 Apache 的配置文件中关闭 PythonDebug

    Off 选项,这个会在 Django 被加载以前去除出错消息。

    安全领域的总结

    我们希望关于安全问题的讨论,不会太让你感到恐慌。Web 是一个处处布满陷阱的世界,但是只要有一些远见,你就能拥有安全的站点。

    永远记住,Web 安全是一个不断发展的领域。如果你正在阅读这本书的停止维护的那些版本,请阅读最新 版本的这个部分来检查最新发现的漏洞。事实上,每周或者每月花点时间挖掘

    web 应用安全,并且跟上最新的动态是一个很好的主意。小小的投入,却能收获保护 你的站点和用户的无价的回报。

    接下来?

    下一章中,我们会谈论到一些使用 Django 的细节问题:如何部署一个站点,并具有良好的伸缩性。