在沙盒化 iframe 中安全畅玩

Mike West

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

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

最小权限

从本质上讲,我们希望找到一种机制,让我们能够为嵌入的内容授予仅执行其工作所需的最低级别功能。如果 widget 不需要弹出新窗口,则取消对 window.open 的访问权限不会有害处。如果该应用不需要 Flash,关闭插件支持应该不会有问题。如果我们遵循最小权限原则,并屏蔽与我们要使用的功能没有直接关系的所有功能,就能尽可能提高安全性。这样一来,我们就不必再盲目相信某些嵌入内容不会利用不应使用的特权。它根本无法访问该功能。

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

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

信任,但要验证

Twitter 的“发推文”按钮就是一个很好的示例,它可以通过沙盒更安全地嵌入到您的网站中。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 不会在框架文档中执行。这不仅包括通过 script 标记显式加载的 JavaScript,还包括内联事件处理程序和 javascript: 网址。这也意味着,noscript 标记中包含的内容将会显示,就像用户自行停用了脚本一样。
  • 框架文档会加载到唯一的来源,这意味着所有同源检查都会失败;唯一的来源永远不会与任何其他来源匹配,甚至不会与自身匹配。除其他影响之外,这还意味着文档无法访问存储在任何来源的 Cookie 或任何其他存储机制(DOM 存储空间、Indexed DB 等)中的数据。
  • 框架文档无法创建新窗口或对话框(例如,通过 window.opentarget="_blank")。
  • 无法提交表单。
  • 插件无法加载。
  • 框架文档只能导航自身,而不能导航其顶级父级。设置 window.top.location 会抛出异常,而点击带有 target="_top" 的链接不会产生任何影响。
  • 系统会屏蔽自动触发的功能(自动聚焦的表单元素、自动播放的视频等)。
  • 无法获取指针锁定。
  • 框架文档包含的 iframes 上的 seamless 属性会被忽略。

这非常严厉,而且将文档加载到完全沙盒化的 iframe 中确实风险很小。当然,它也无法发挥太大作用:对于某些静态内容,您或许可以使用完整沙盒,但在大多数情况下,您会希望放宽一些限制。

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

  • 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 只会获得所需的权限,而插件、顶部导航栏和指针锁定等功能仍会被屏蔽。我们降低了嵌入该 widget 的风险,且没有任何副作用。 这对所有相关方来说都是有利的。

权限分离

将第三方内容沙盒化以便在低特权环境中运行其不可信代码,显然有益。但您自己的代码呢?您相信自己,对吗?那么,为什么要担心沙盒化?

我来反问一下:如果您的代码不需要插件,为什么要向其授予对插件的访问权限?在最好的情况下,这项特权是您从不使用的;在最坏的情况下,这项特权是攻击者入侵的潜在途径。每个人的代码都有 bug,几乎每个应用都或多或少存在被利用的漏洞。对您自己的代码进行沙盒化处理意味着,即使攻击者成功破坏了您的应用,他们也无法获得对应用源代码的完整访问权限;他们只能执行应用可以执行的操作。虽然这仍然很糟糕,但总比更糟糕的情况要好。

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

安全地将 eval() 放入沙盒

借助沙盒化和 postMessage API,我们可以非常轻松地将此模型的成功经验应用于 Web 平台。应用的各个部分可以位于沙盒化 iframe 中,父文档可以通过发布消息和监听响应来协调它们之间的通信。这种结构可确保应用的任何部分遭到利用时,造成的破坏最小。这种方法还有一个优势,即迫使您创建明确的集成点,以便您确切知道在哪些位置需要小心验证输入和输出。我们来看看一个简单的示例,看看它是如何运作的。

Evalbox 是一款非常有趣的应用,它接受字符串,并将其作为 JavaScript 进行求值。太棒了,对吧?这正是您多年来一直在等待的。当然,这是一种相当危险的应用,因为允许执行任意 JavaScript 意味着来源提供的所有数据都可能被盗用。我们将确保代码在沙盒中执行,以降低发生“坏事”™的风险,这样会更安全。我们将从内部开始逐步了解代码,首先从帧的内容入手:

<!-- 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-originallow-scripts 标志的沙盒对同一源的另一个网页进行嵌套,则嵌套的网页可以向上到达父级,并完全移除沙盒属性。

在沙盒中玩游戏

目前,您可以在多种浏览器中使用沙盒功能:Firefox 17 及更高版本、IE10 及更高版本,以及 Chrome(在撰写本文时是这样,当然,caniuse 提供了最新的支持表格)。将 sandbox 属性应用于您包含的 iframes 后,您可以向其显示的内容授予特定权限,授予这些内容正常运行所需的权限。这样一来,您就可以降低与包含第三方内容相关的风险,而这超出了内容安全政策现有的做法。

此外,沙盒是一种强大的技术,可降低聪明的攻击者利用您自己代码中的漏洞的风险。通过将单体式应用拆分为一组沙盒化服务(每个服务负责一小部分自包含功能),攻击者将不得不不仅破坏特定帧的内容,还破坏其控制器。这项任务要困难得多,尤其是因为控制器的范围可以大大缩小。如果您让浏览器帮助处理其余工作,则可以将安全相关工作集中在审核代码上。

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

延伸阅读

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

  • 将沙盒功能与另外两个新的 iframe 属性(srcdocseamless)结合使用时,沙盒功能会变得更加灵活。前者可让您在不产生 HTTP 请求开销的情况下向框架中填充内容,而后者可让样式流入框架内容。目前,这两种技术的浏览器支持情况都很糟糕(Chrome 和 WebKit 的夜间版),但未来会成为一个有趣的组合。例如,您可以使用以下代码对文章中的评论进行沙盒化处理:

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