在沙盒化 iframe 中安全畅玩

Mike West

在当今的网络上构建丰富的体验几乎不可避免地涉及嵌入您无法真正控制的组件和内容。 第三方 widget 可以提高互动度,并在整体用户体验中发挥关键作用,而用户生成的内容有时甚至比网站的原生内容更重要。这两者都无法避免,但都会增加网站上发生不良事件的风险。您嵌入的每个微件(每个广告、每个社交媒体微件)都是恶意攻击者的潜在攻击途径:

内容安全政策 (CSP) 允许您将特定可信脚本和其他内容来源列入许可名单,从而降低与这两种类型的内容相关的风险。这是朝着正确方向迈出的重要一步,但值得注意的是,大多数 CSP 指令提供的保护都是二元的:要么允许资源,要么不允许。有时,您可能想说:“我不确定自己是否真的信任此内容来源,但它太漂亮了!请嵌入它,但不要让它破坏我的网站。”

最小权限

从本质上讲,我们正在寻找一种机制,使我们能够仅向嵌入的内容授予执行其工作所需的最低级别功能。如果微件不需要弹出新窗口,那么移除对 window.open 的访问权限不会造成任何损害。如果不需要 Flash,关闭插件支持应该不会有问题。如果我们遵循最小权限原则,并屏蔽与我们想要使用的功能不直接相关的每个功能,那么我们的安全性就会尽可能高。这样一来,我们就不必再盲目相信某些嵌入式内容不会利用其不应使用的权限。它只是无法访问该功能。

iframe 元素是为此类解决方案构建良好框架的第一步。在 iframe 中加载一些不受信任的组件,可在应用与您要加载的内容之间提供一定程度的分隔。框架内容将无法访问您页面的 DOM 或您存储在本地的数据,也无法在页面上的任意位置绘制;其范围仅限于框架的轮廓。不过,这种分离并不真正可靠。包含的页面仍有许多选项可用于执行烦人或恶意行为:自动播放视频、插件和弹出式窗口只是冰山一角。

iframe 元素的 sandbox 属性正是我们加强对框架内容限制所需要的。我们可以指示浏览器在低权限环境中加载特定框架的内容,仅允许执行所需工作所需的子集功能。

信任,但要验证

Twitter 的“Tweet”按钮就是一个很好的例子,它可以通过沙盒更安全地嵌入到您的网站中。Twitter 允许您通过 iframe 嵌入按钮,代码如下:

<iframe src="https://platform.twitter.com/widgets/tweet_button.html"
        style="border: 0; width:130px; height:20px;"></iframe>

为了确定可以锁定哪些内容,我们来仔细检查一下该按钮需要哪些功能。加载到框架中的 HTML 会执行来自 Twitter 服务器的一小段 JavaScript,并在点击时生成一个填充了推文界面的弹出式窗口。该界面需要访问 Twitter 的 Cookie,才能将推文与正确的账号相关联,并且需要能够提交推文表单。基本上就是这样;框架不需要加载任何插件,不需要导航顶级窗口,也不需要执行许多其他功能。由于它不需要这些权限,因此我们通过对框架内容进行沙盒化处理来移除这些权限。

沙盒功能基于许可名单运行。我们首先会移除所有可能的权限,然后通过向沙盒配置添加特定标志来重新启用各个功能。对于 Twitter 微件,我们决定启用 JavaScript、弹出式窗口、表单提交和 twitter.com 的 Cookie。为此,我们可以向 iframe 添加一个 sandbox 属性,并为其指定以下值:

<iframe sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
    src="https://platform.twitter.com/widgets/tweet_button.html"
    style="border: 0; width:130px; height:20px;"></iframe>

大功告成。我们已向该框架授予其所需的所有功能,浏览器会帮助拒绝其访问我们未通过 sandbox 属性值明确授予它的任何权限。

精细控制功能

我们在上面的示例中看到了一些可能的沙盒标志,现在让我们更详细地了解该属性的内部工作原理。

如果 iframe 具有空的 sandbox 属性,则框架文档将完全沙盒化,并受到以下限制:

  • JavaScript 不会在框架文档中执行。这不仅包括通过脚本标记显式加载的 JavaScript,还包括内嵌事件处理脚本和 javascript: 网址。这也意味着,noscript 标记中包含的内容将会显示,就像用户自己停用了脚本一样。
  • 框架文档会加载到唯一源中,这意味着所有同源检查都会失败;唯一源永远不会与其他任何源匹配,甚至不会与自身匹配。除其他影响外,这意味着文档无法访问存储在任何来源的 Cookie 或任何其他存储机制(DOM 存储、Indexed DB 等)中的数据。
  • 框架文档无法创建新窗口或对话框(例如,通过 window.open 或 target="_blank")。
  • 无法提交表单。
  • 插件将无法加载。
  • 框架文档只能自行导航,而不能导航其顶级父级。 设置 window.top.location 会抛出异常,点击包含 target="_top" 的链接不会产生任何效果。
  • 自动触发的功能(自动聚焦的表单元素、自动播放的视频等)会被屏蔽。
  • 无法获取指针锁定。
  • seamless 属性在框架文档包含的 iframes 上会被忽略。

这种做法非常严苛,加载到完全沙盒化的 iframe 中的文档确实几乎没有风险。当然,它也无法执行太多有价值的操作:对于某些静态内容,您可能可以采用完全沙盒化,但大多数情况下,您需要稍微放宽限制。

除了插件之外,您可以通过向 sandbox 属性的值添加标志来解除上述每项限制。沙盒化文档永远无法运行插件,因为插件是未沙盒化的原生代码,但其他所有内容都是公平的:

  • allow-forms 允许提交表单。
  • allow-popups 允许(令人震惊!)弹出式窗口。
  • allow-pointer-lock 允许(惊喜!)指针锁定。
  • allow-same-origin 允许文档保留其源;从 https://example.com/ 加载的页面将保留对该源数据的访问权限。
  • allow-scripts 允许执行 JavaScript,还允许自动触发功能(因为通过 JavaScript 实现这些功能很简单)。
  • allow-top-navigation 允许文档通过导航顶级窗口来跳出框架。

考虑到这些因素,我们可以准确评估为什么最终在上述 Twitter 示例中使用了特定的沙盒标志集:

  • allow-scripts 是必需的,因为加载到框架中的网页会运行一些 JavaScript 来处理用户互动。
  • allow-popups 是必需的,因为该按钮会在新窗口中弹出推文表单。
  • allow-forms 是必需的,因为推文表单应该可以提交。
  • allow-same-origin 是必需的,否则将无法访问 twitter.com 的 Cookie,用户也无法登录以发布表单。

需要注意的重要一点是,应用于框架的沙盒化标志也适用于在沙盒中创建的任何窗口或框架。这意味着,即使表单仅存在于框架弹出的窗口中,我们也必须将 allow-forms 添加到框架的沙盒中。

设置 sandbox 属性后,该 widget 仅会获得其所需的权限,而插件、顶级导航和指针锁定等功能仍会被屏蔽。我们降低了嵌入该微件的风险,且不会产生任何不良影响。 这对所有相关方来说都是有利的。

权限分离

对第三方内容进行沙盒化处理,以便在低特权环境中运行其不可信代码,这样做的好处显而易见。但您自己的代码呢?您相信自己,对吧?那为什么还要担心沙盒化呢?

我想反问一下:如果您的代码不需要插件,为什么要让它访问插件?在最好的情况下,这是您永远不会使用的权限;在最坏的情况下,这是攻击者进入系统的潜在途径。每个人的代码都存在 bug,几乎每个应用都容易受到某种方式的攻击。对您自己的代码进行沙盒化意味着,即使攻击者成功破坏了您的应用,他们也不会获得对应用来源的完全访问权限;他们只能执行应用可以执行的操作。虽然这仍然很糟糕,但情况并没有那么糟糕。

您还可以将应用拆分为逻辑部分,并使用尽可能低的权限对每个部分进行沙盒化处理,从而进一步降低风险。这种技术在原生代码中非常常见:例如,Chrome 会将自身拆分为一个高特权浏览器进程(可以访问本地硬盘并建立网络连接)和许多低特权渲染器进程(负责解析不受信任的内容)。渲染程序无需访问磁盘,浏览器会负责向其提供渲染网页所需的所有信息。即使聪明的黑客找到了破坏渲染器的方法,也无法造成太大危害,因为渲染器本身无法执行太多有价值的操作:所有高权限访问都必须通过浏览器的进程进行路由。 攻击者需要在系统的不同部分找到多个漏洞才能造成任何损害,这大大降低了成功入侵的风险。

正在安全地将 eval() 放入沙盒

借助沙盒和 postMessage API,此模型可以非常简单地应用于 Web。应用的各个部分可以位于沙盒化 iframe 中,父文档可以通过发布消息和监听响应来协调它们之间的通信。这种结构可确保应用中任何一个部分的漏洞造成的损害尽可能小。它还有一个优势,就是会迫使您创建清晰的集成点,这样您就可以确切地知道在哪些地方需要谨慎验证输入和输出。我们来看一个简单的示例,了解其运作方式。

Evalbox 是一款有趣的应用,它会接收一个字符串,并将其作为 JavaScript 进行求值。哇,对吧?正是你多年来一直期待的。当然,这是一种相当危险的应用,因为允许执行任意 JavaScript 意味着源站提供的任何和所有数据都可能被窃取。我们将通过确保代码在沙盒内执行来降低发生 Bad Things™ 的风险,从而提高安全性。我们将从内到外逐步分析代码,首先从框架的内容开始:

<!-- frame.html -->
<!DOCTYPE html>
<html>
    <head>
    <title>Evalbox's Frame</title>
    <script>
        window.addEventListener('message', function (e) {
        var mainWindow = e.source;
        var result = '';
        try {
            result = eval(e.data);
        } catch (e) {
            result = 'eval() threw an exception.';
        }
        mainWindow.postMessage(result, event.origin);
        });
    </script>
    </head>
</html>

在框架内,我们有一个极简文档,它只是通过挂接到 window 对象的 message 事件来监听来自其父级的消息。 每当父级对 iframe 的内容执行 postMessage 时,此事件都会触发,从而使我们能够访问父级希望我们执行的字符串。

在处理脚本中,我们获取事件的 source 属性,即父窗口。我们将在完成工作后使用此通道将工作结果发送回去。然后,我们将通过将给定的数据传递到 eval() 来完成繁重的工作。此调用已封装在 try 块中,因为沙盒化 iframe 中的禁止操作会频繁生成 DOM 异常;我们将捕获这些异常并报告友好的出错提示。最后,我们将结果发布回父窗口。这些内容都非常简单明了。

父级也同样简单。我们将创建一个小型界面,其中包含一个用于代码的 textarea 和一个用于执行的 button,并通过沙盒化 iframe 引入 frame.html,仅允许执行脚本:

<textarea id='code'></textarea>
<button id='safe'>eval() in a sandboxed frame.</button>
<iframe sandbox='allow-scripts'
        id='sandboxed'
        src='frame.html'></iframe>

现在,我们将连接各个部分以执行。首先,我们将监听来自 iframe 的响应,并将其alert()给用户。实际应用可能会执行一些不那么烦人的操作:

window.addEventListener('message',
    function (e) {
        // Sandboxed iframes which lack the 'allow-same-origin'
        // header have "null" rather than a valid origin. This means you still
        // have to be careful about accepting data via the messaging API you
        // create. Check that source, and validate those inputs!
        var frame = document.getElementById('sandboxed');
        if (e.origin === "null" &amp;&amp; e.source === frame.contentWindow)
        alert('Result: ' + e.data);
    });

接下来,我们将事件处理脚本与 button 的点击操作关联起来。当用户点击时,我们将获取 textarea 的当前内容,并将其传递到框架中执行:

function evaluate() {
    var frame = document.getElementById('sandboxed');
    var code = document.getElementById('code').value;
    // Note that we're sending the message to "*", rather than some specific
    // origin. Sandboxed iframes which lack the 'allow-same-origin' header
    // don't have an origin which you can target: you'll have to send to any
    // origin, which might alow some esoteric attacks. Validate your output!
    frame.contentWindow.postMessage(code, '*');
}

document.getElementById('safe').addEventListener('click', evaluate);

很简单,对吧?我们创建了一个非常简单的评估 API,并且可以确保被评估的代码无法访问 Cookie 或 DOM 存储等敏感信息。同样,被求值的代码无法加载插件、弹出新窗口或执行其他许多令人讨厌或恶意的活动。

您也可以对自己的代码执行相同的操作,方法是将单体式应用拆分为单一用途的组件。每个都可以封装在一个简单的消息传递 API 中,就像我们上面编写的那样。高权限父窗口可以充当控制器和调度器,向特定模块发送消息,每个模块都具有尽可能少的权限来执行其工作,监听结果,并确保每个模块都仅获得其所需的信息。

不过请注意,在处理来自与父级相同来源的框架内容时,您需要非常小心。如果 https://example.com/ 上的某个网页使用同时包含 allow-same-origin 和 allow-scripts 标志的沙盒来框架同一来源上的另一个网页,则框架网页可以访问父级网页,并完全移除沙盒属性。

在沙盒中畅玩

目前,您可以在各种浏览器中使用沙盒:Firefox 17+、IE10+ 和 Chrome(在撰写本文时,caniuse 当然有最新的支持表)。通过将 sandbox 属性应用于您添加的 iframes,您可以向其显示的内容授予特定权限,仅授予内容正常运行所需的权限。这样,您就有机会降低与包含第三方内容相关的风险,这超出了内容安全政策已经可以实现的范围。

此外,沙盒化是一种强大的技术,可降低狡猾的攻击者利用您自己代码中的漏洞的风险。通过将单体式应用拆分为一组沙盒化服务(每个服务负责一小部分自包含功能),攻击者不仅需要入侵特定框架的内容,还需要入侵其控制器。这是一项更艰巨的任务,尤其是因为控制器的范围可以大大缩小。如果您向浏览器寻求帮助,则可以将安全相关工作花在审核该代码上。

这并不是说沙盒化是解决互联网安全问题的完整解决方案。它提供纵深防御,除非您能控制用户的客户端,否则您还无法依赖浏览器为所有用户提供支持(如果您确实能控制用户的客户端,例如企业环境,那就太棒了!)。也许有一天可以…但目前沙盒只是另一层保护,用于加强防御,而不是您可以完全依赖的完整防御。不过,图层功能依然非常出色。我建议您使用此功能。

深入阅读

  • “HTML5 应用中的权限分离”是一篇有趣的论文,其中介绍了小型框架的设计及其在三个现有 HTML5 应用中的应用。

  • 如果将沙盒化与其他两个新的 iframe 属性(srcdoc 和 seamless)结合使用,则可以更加灵活。前者允许您使用内容填充框架,而无需产生 HTTP 请求的开销,后者允许样式流入框架内容。 目前,这两者在浏览器支持方面都相当糟糕(Chrome 和 WebKit 每夜版),但未来会是一个有趣的组合。例如,您可以通过以下代码对文章评论进行沙盒化处理:

        <iframe sandbox seamless
                srcdoc="<p>This is a user's comment!
                           It can't execute script!
                           Hooray for safety!</p>"></iframe>