在沙盒化 iframe 中安全畅玩

Mike West

要在当今的 Web 上构建丰富的体验,您几乎不可避免地需要嵌入您无法实际控制的组件和内容。第三方 widget 可以提高互动度,并在整体用户体验中发挥关键作用,而用户生成的内容有时甚至比网站的原生内容更重要。实际上并不能摆脱其中任何一个,但这两者都会增加您的网站上发生 Things BadTM 的风险。对于那些有恶意企图的用户,您嵌入的每个 widget(每个广告、每个社交媒体 widget)都是潜在的攻击途径:

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

最小权限

从本质上讲,我们在寻找一种机制,使我们能够在授予嵌入内容时仅授予完成其工作所需的最低级别能力。如果 widget 不需要弹出新窗口,则撤消对 window.open 的访问权限不会造成伤害。如果它不需要 Flash,则关闭插件支持不成问题。如果我们遵循最小权限原则,尽可能确保安全,并屏蔽与我们希望使用的功能不直接相关的每项功能。这样一来,我们再也不必盲目相信某些嵌入内容不会利用它本不应使用的权限。而起初,它无权访问该功能。

iframe 元素是为此类解决方案构建良好框架的第一步。在 iframe 中加载某些不受信任的组件可以衡量您的应用与要加载的内容之间的分隔情况。框架内容无法访问网页的 DOM 或您存储在本地的数据,也无法绘制到网页上的任意位置;它只能在框架的轮廓范围内。不过,这种分离并不是真正稳健。包含的页面仍然有很多选项来应对令人厌烦或恶意行为:自动播放视频、插件和弹出式窗口只是冰山一角。

iframe 元素的 sandbox 属性可以为我们提供必要的信息,以便严格遵守对框架内容的限制。我们可以指示浏览器在低权限环境中加载特定帧的内容,从而仅允许执行执行所需工作所需的部分功能。

Twust,但要验证

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 微博界面的弹出式窗口。该界面需要访问 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 的沙盒属性为空,带框架的文档将进行完全沙盒化,并受到以下限制:

  • JavaScript 不会在框架文档中执行。这不仅包括通过脚本代码明确加载的 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 是必需的,因为按钮会在新窗口中打开 Twitter 微博表单。
  • allow-forms 是必填项,因为 Twitter 微博表单应该可以提交。
  • allow-same-origin 是必需的,因为 twitter.com 的 Cookie 无法访问,而且用户无法登录以发布表单。

请务必注意,应用于帧的沙盒标记也会应用于沙盒中创建的所有窗口或帧。这意味着,即使表单仅存在于框架弹出的窗口中,我们也必须将 allow-forms 添加到框架的沙盒中。

添加 sandbox 属性后,widget 将仅获得其所需的权限,并且插件、顶部导航和指针锁定等功能将保持阻塞状态。我们已降低了嵌入该 widget 的风险,并且没有产生任何负面影响。 人人都能受益。

特权分离

显然,通过沙盒化第三方内容在低权限环境中运行其不可信代码是非常有益的。可是你自己的代码呢?你相信自己,对吧?那么,为什么要担心沙盒呢?

我可以反过来这样一个问题:如果您的代码不需要插件,为什么还要向其提供插件的访问权限?最好是您从不使用的特权,但最坏的是,它可能会成为攻击者入侵门的媒介。每个人都有代码存在 bug,并且几乎所有应用都容易以某种方式被利用。将您自己的代码沙盒化意味着,即使攻击者成功破坏了您的应用,他们也无法获得应用来源的完整访问权限;他们只能执行应用能够执行的操作。虽然仍然很糟糕,但并不像其想象的那么糟糕。

您可以将应用拆分为多个逻辑部分,然后以尽可能少的权限对其进行沙盒化处理,从而进一步降低风险。这种方法在原生代码中很常见:例如,Chrome 会自行拆分为一个高权限浏览器进程(该进程有权访问本地硬盘且可连接到网络),以及许多可执行繁重解析不受信任内容的低权限渲染程序进程。渲染程序无需处理磁盘,浏览器会负责为其提供渲染网页所需的所有信息。即使聪明的黑客找到了损坏渲染器的方法,但由于渲染程序本身无法执行太多相关操作,因此她并没有取得了太大的进展:所有高特权访问权限都必须通过浏览器的进程进行路由。攻击者需要在系统的不同部分找到多个漏洞,以便造成任何损害,从而极大地降低攻击成功的风险。

安全地将 eval() 沙盒化

借助沙盒和 postMessage API,可以非常轻松地将这种模型的成功应用于 Web 平台。应用的各部分可位于沙盒化 iframe 中,并且父文档可通过发布消息和监听响应来代理这些部分之间的通信。这种结构可确保应用中任何一个部分的漏洞对造成的损害最小。它还具有强制您创建清晰的集成点的优势,以便您确切知道需要注意哪些地方验证输入和输出。我们来看一个玩具示例,看看如何操作。

Evalbox 是一款出色的应用,它接受一个字符串,并将其作为 JavaScript 进行评估。哇,对吗?这是你等这些年来期待的。当然,这是一个相当危险的应用,因为允许执行任意 JavaScript 意味着源站提供的任何和所有数据都可供获取。为降低 Bad ThingsTM 的风险,我们将确保代码在沙盒内执行,从而提高安全性。我们将从内而外逐一分析代码,从帧的内容开始:

<!-- 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 应用中的特权分离》一文中介绍了一篇有趣的论文,它完整地介绍了一个小型框架的设计,并介绍了它在 3 个现有 HTML5 应用中的应用。

  • 与其他另外两个新的 iframe 属性(srcdocseamless)结合使用,沙盒会变得更加灵活。前一种方式让您可以在没有 HTTP 请求开销的情况下使用内容填充帧,而后一种方式则可让样式流入加框内容中。目前,两者的浏览器支持非常糟糕(Chrome 和 WebKit Nightlies),但将来会成为一个有趣的组合。例如,您可以通过以下代码对某篇文章进行沙盒注释:

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