本文由体验技术团队黄琦同学翻译。
原文链接: https://weizmangal.com/2022/10/28/what-is-a-realm-in-js/
github仓库地址: https://github.com/weizman/weizman.github.io/blob/gh-pages/_posts/2020-02-02-what-is-a-realm-in-js.md
前言
作为我对浏览器 JavaScript 安全性的长期研究的一部分,在过去的一年里,我一直特别关注 realms 的安全性⭐️。
由于“基于依赖库”的开发模式的兴起,JavaScript 生态系统(尤其是浏览器里面的 JavaScript 生态系统)也就更容易受到所谓的“供应链攻击”。JavaScript 中存在一种“创建新 realm”的能力,这一能力也被成功利用来对 web 应用程序实施供应链攻击(如果你想知道为什么它能被用于实施供应链攻击,建议阅读我以前发的这篇推文)。
在“raelm 安全”这一领域,我们目前还处于任重道远的状态。我希望通过引入一款开源工具来逐步解决这个问题。这是第一款开源的 realms 安全工具——Snow-JS ❄️,作者是 LavaMoat 🌋 。这个库还在早期阶段,正在演进中,敬请期待!
为了讲清楚我做这些事情的意义,我们必须先理解“什么是 realm”。显然,我们很难用一种正确、通俗、有启发性的方式去回答这个问题。
这篇文章主要围绕浏览器中的 JavaScript 进行讨论,它也有可能通用于所有的 JavaScript 环境,这我无法保证。
realm —— JavaScript 生存的世界
你可以通俗地认为,realm 大致就是一个生态系统,JavaScript 程序就生存在这个生态系统中。就如同其他任何一个生态系统一样,它里面包含了 JavaScript 程序生存所需的各种要素。
那么,哪些东西是 JavaScript 程序所需的呢?
1) 全局执行环境
在 JavaScript 中,可以有许多不同的脚本在同一环境中运行。这些脚本可以形成作用域(scopes)。作用域是一种符合规范的执行环境,值和表达式在其中“可见”或可被访问。作用域也可以堆叠成层次结构,子作用域可以访问父作用域,反过来则不行:
<script>(function scope1() {const x = 1;(function scope2() {const y = x + 2; // 3}());const z = x + y; // Uncaught ReferenceError: y is not defined}());
</script>
在上面的示例中,我们展示了如何使用 JavaScript 定义作用域。但是,假如我们编写了一个 JavaScript 程序,它只声明变量,而没有声明作用域,那会怎么样呢?
这被称为“顶级声明(top level declaration)”——任何没在已定义的作用域内声明或运行的东西,它们都位于默认的最外层作用域(outer-most scope)下,即全局执行环境(global execution environment)。
在这个最外层作用域下面声明的变量,它们会被全局执行环境下的不同脚本所共享:
<script> const x = 1; </script>
<script> const y = 2; </script>
<script> const z = x + y; // 3 </script>
realm 为 JavaScript 程序提供了属于自己的一个全局执行环境
上述的示例使用的是 const
,它的作用是在“声明式环境(declarative environment)”中定义了一个新东西,它与 let
、class
、module
、import
以及 function
声明是一类东西。
用其他方式定义出来的东西都是在“对象式环境(object environment)”中,包括 var
, function
, async function
, function*
, async function*
(*
表示生成器函数)。
注意,当 JavaScript 代码在严格模式下执行以及作为模块代码执行时,不同的声明语句通过“对象式环境(object environment)”对全局对象(global object)的影响与上述解释是有偏差的!
“声明式环境(declarative environment)”和“对象式环境(object environment)”共同构成了前面提到的全局执行环境。
除了上述内容外,“对象式环境(object environment)”还提供了所有的“内置全局对象(built-in global objects)”,因为它的基础对象就是所谓的“全局对象(global object)”。
译者注:这个章节只是由于撰写需要,简单地提了一下“declarative environment”和“object environment”的概念,对定义的介绍并不完善。经过本人的核对,我认为他对“object environment”的解释甚至是错的。如果读者想要更好地理清这些东西,建议去找一些介绍 JavaScript 词法环境的资料看一看。
2) 全局对象 global object (和内部对象 intrinsic objects)
在拥有一个像样的可以执行 JavaScript 程序的环境之后,它们还需要能够执行高级操作,包括但不限于基于平台的操作。
全局对象提供了访问内置对象(built-ins)的功能,例如各种内部对象(intrinsics)、对象(object)、API 等(无论是否特定于平台)。由于这些东西的存在,全局对象的功能变得更丰富、更全面、更有用。
你可以在浏览器中通过
window
引用到全局对象,也可以在 NodeJS 中通过global
引用到全局对象。此外还有一个通用的globalThis
变量,它在这两个环境中都能用来访问全局对象。
首先,我们先看一下平台无关的部分。全局对象暴露了一些内置的内部对象( built-in intrinsic objects):
- 值(values)(例如
undefined
、Infinity
等); - 函数(functions) (例如
eval
、parseInt
等); - 构造函数(constructors) (例如
Boolean
、Date
等); - 其他(others) (例如
JSON
、Math
等)
除了上面那些以外,全局对象还暴露了不同平台特有的 API。
例如浏览器中就有 fetch
、alert
、document
等 API。
例如 DOM
就是一套众所周知的、浏览器特有的 API。它通过全局对象暴露出来。在这里,每一个 realm 都有它唯一独立的 DOM。
我们续接上面“全局执行环境”那一章节的末尾继续讲吧。全局对象除了导出那些内置对象(built-ins)之外,它还导出“对象式环境(object environment)”下声明的内容:
// `const` 声明的东西是处于“声明式环境(declarative environment)”下的
const constant = 1;// 因此它们无法通过全局对象去访问到
console.log(window.constant); // undefined// 然而,// `var` 声明的东西是处于“对象式环境(object environment)” 下的
var variable = 2;// 因此它们可以通过全局对象去访问到
console.log(window.variable); // 2
任何平台特定的对象和 API,它们都可以通过全局对象来访问。
译者注:这句话太拗口,本人翻译不出来,机翻的逻辑也是不通顺的。所以只能退而求其次,仅保留最关键的信息,以不堵塞阅读为目标。原句在这里,有缘人看到且有兴趣的话可以帮忙翻译一下:Any platform specific objects and APIs are accessible via the global object along with all intrinsic objects and new properties declared by code.
3) JavaScript 本身
最后要提的,就是在这个 realm 的执行环境中运行的 JavaScript 代码了,它也是可以与 realm 相关联的。
对执行环境(execution environment)、全局对象(global object)或在某个 realm 下产生的任何内容进行的更改/变换/更新,也都会被关联到某个唯一特定的 realm 上。
掌握“realm”的真实概念
恭喜你🎉看完了无聊的技术定义部分。接下来开始进入“不那么严肃”的内容,一切都会顺利!
1)“现实生活”中的 Realms
我们在前面强调过,realms 是一个 JavaScript 概念,并非浏览器独有。但我接下来仍会选择基于浏览器去讨论它。
现在我们已经定义了什么是领域,是时候“一睹真容”了。
在浏览器中,默认情况下只有一个 realm,即 top main realm。这就是浏览器加载的 web 应用程序所在的 realm。
正如我们刚刚学到的,Web 应用程序位于该 realm 内,该 realm 为其提供了全局执行环境(global execution environment)、最外层作用域(outer-most scope)和全局对象(global object),这个全局对象让我们得以访问各种内部对象(intrinsic objects)、平台特定 API 等。
然而,除了那个默认的 realm 以外,web 应用程序也可以创建出新的 realm,不同的 realm 是可以共存的。并且每个新的 realm 都将拥有一份独属于自己的全局执行环境、最外层作用域、全局对象… 。
每个 realm 都存在于一个 agent 内, 一个 agent 下可以有多个 realm。realm 则可以有子 realm 或同级 realm。
agent 将在另一篇文章中单独介绍。现在我们只需要知道,agent 是一个实体,它向自己下辖的 realm 提供不同的资源(例如事件循环)。
译者注:在 agent 之上还有 agent cluster 的概念。它们的层级关系是这样的:agent cluster -> agent -> realm。ECMAScript 规范文档里面有详细解释了这些概念。下面的内容会涉及到这几个名词,所以先在这里介绍一下。
在浏览器中,可以通过多种方式创建 realm。它们是否是同一个 agent 的子级,取决于 realm 的性质以及它们之间的关系。
这里有些例子:
- 同源的两个 iframe (无论是父子关系还是兄弟关系) 将在单个 agent 下形成两个 realm。
- 非同源的两个 iframe (无论是父子关系还是兄弟关系) 将会在不同的两个 agent 下各自形成一个 realm。(此外,为了保持跨源站点隔离,这两个 agent 甚至也是隶属于两个运行在不同进程中的不同 agent cluster 。)
- top main realm 和 service worker 也属于不同的两个 agent,但是这两个 agent 属于同一个 agent cluster (web worker 也是如此)。
这些关系还决定了 realm 之间可以进行何种程度的通信。
同源 iframe 的 realm 之间共享同一个事件循环,并且可以使用 contentWindow
属性,同步且自由地访问彼此的环境:
// https://example.com
const ifr = document.createElement('iframe');
ifr.src = 'https://example.com'; // same origin
ifr.onload = () => {console.log(ifr.contentWindow.document.body);// <body></body>
}
document.body.appendChild(ifr);
但跨源 iframe 的 realm 去使用这个 API 就会受到更多的访问限制:
// https://example.com
const ifr = document.createElement('iframe');
ifr.src = '//cross.origin.com'; // cross origin
ifr.onload = () => {console.log(ifr.contentWindow.document.body);// Uncaught DOMException: Blocked a frame with origin "https://example.com" from accessing a cross-origin frame.
}
document.body.appendChild(ifr);
跨源 realm 仍然可以相互通信,但通信受到更多限制,且只能基于 postMessage() 异步 API。这在 web worker、service worker 等场景下也成立。
值得一提的是,一旦著名的 shadow realms 提案落地,很快就会出现各种有趣的补充解决方案,它们可以解决此处描述的一些限制。这是一个值得持续关注的东西!
感受一下 realm 的独特性,有助于更好理解 realm 这一概念
例如,我们加载以下网站:
<html><head></head><body><iframe id="some_iframe"></iframe></body>
</html>
这么一来,里面就会有两个不同的 realm。一个是 top main realm,另一个是 iframe 里面新建的 realm。每个 realm 都有它的唯一身份标识,具有唯一的全局对象和全局执行环境:
window === some_iframe.contentWindow // false
每个 realm 都有属于自己的一组内部对象(intrinsic objects)和基于平台的 API:
window.fetch === some_iframe.contentWindow.fetch // false
window.Array === some_iframe.contentWindow.Array // false
<html><script> window.top_array = []; </script><iframe> <script> window.top.iframe_array = []; </script> </iframe><script>// top_array 和 iframe_array 诞生于不同的 realm(所以他们的原型不是同一个对象)Object.getPrototypeOf(window.iframe_array) === Object.getPrototypeOf(window.top_array) // false</script>
</html>
原始数据类型(Primitives)则不一样,即使跨了 realm,它们也是全等的。
window.Infinity === some_iframe.contentWindow.Infinity // true
2)身份不连续性
身份不连续性(identity discontinuity)是随着 realm 的存在而出现的一种特征性的状态。根据这一特征,我们能更明显的感知到 realm 的独特之处。
译者注:identity discontinuity 是一个心理学和社会科学上会使用到的术语,在 Javascript 的一些底层知识的文档和讨论中也有广泛地使用这个词来描述一种特别的现象,但本人找不到合适的中文翻译,所以使用的是机翻的结果。
为了充分地演示这个概念,我们将使用 instanceof
运算符进行演示。
想象一下,我们有一个第三方服务,它的功能是创建一个蓝色按钮。他被以 iframe 的形式加载(不要问为什么),以便 Web 应用程序可以按如下方式使用其服务:
<html><iframe id="blue_buttons_iframe"><script>window.top.createBlueButton = function(text) {const button = document.createElement('button');button.style.color = 'blue';button.value = text;return button;};</script></iframe><body><script>const blueButton = window.createBlueButton('my blue button');if (!blueButton instanceof HTMLButtonElement) {throw new Error('blue button created does not seem to actually be a button element!');}document.body.appendChild(blueButton);</script></body>
</html>
使用 instanceof
,你可以判断运算符左侧的内容是否是其右侧内容的实例。例如,由于 button
元素是 HTMLButtonElement
接口的实例,所以 document.createElement('button') instanceof HTMLButtonElement
的结果是 true
,而 document.createElement('div') instanceof HTMLButtonElement
的结果是 false
——因为 div
元素继承自 HTMLDivElement
而不是 HTMLButtonElement
,这是显而易见的。
然而,在我们的示例中,instanceof
检查将返回 false
,并且将抛出自定义错误——即使 blueButton
继承自HTMLButtonElement
。
这太不可思议了,是吧?出现这种情况是有原因的,虽然它确实继承自 HTMLButtonElement
,但总的来说,这种情况还不足以算作 “instance of”——因为,被测试的对象必须是“生下”它的那个 realm 的接口的实例。
进行 instanceof
检查的最初目的,是为了确保蓝色按钮第三方服务确实提供了按钮元素,而不是其他任何元素。但实际上蓝色按钮的来源和 HTMLButtonElement
接口的来源不是同一个realm,因此 instaceof
检查将永远返回 false
。
上面所描述的这个错误,是由于代码中引入了身份不连续性(identity discontinuity),这表明了 realm 及其提供的一切事物是多么的独一无二。
解决身份不连续性(identity discontinuity)不见得是容易的。在上面的示例中,将检查的代码更改为 blueButton instanceof blue_buttons_iframe.contentWindow.HTMLButtonElement
是可以解决这个问题,但这不是一个可扩展的解决方案,也不是一个便利的解决方案。
为了使一个对象成为一个接口的实例,这个对象必须来自或创建于这个接口所在的 realm。
总结
我之所以整理出这些内容,是因为我没有找到任何有用、准确、易于理解的信息,没有一些现成的信息能够向我解答 “什么是realm” “realm的定义” 这些问题。为了更深入地了解 realm 在供应链攻击和安全中的作用,首先我需要充分了解 realm,这是对我来说至关重要的。我希望这些内容对你也有用。
你可以随时在 awesome-JavaScript-realms-security 这个仓库上了解我对这个领域的研究和开发。
我还建议你更多地了解 LavaMoat 🌋 开发的工具 Snow-JS ❄️ ,以进一步了解围绕 JavaScript realm 的安全防御工作。
关于 OpenTiny
OpenTiny 是一套企业级 Web 前端开发解决方案,提供跨端、跨框架、跨版本的 TinyVue 组件库,包含基于 Angular+TypeScript 的 TinyNG 组件库,拥有灵活扩展的低代码引擎 TinyEngine,具备主题配置系统TinyTheme / 中后台模板 TinyPro/ TinyCLI 命令行等丰富的效率提升工具,可帮助开发者高效开发 Web 应用。
欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~更多视频内容也可关注B站、抖音、小红书、视频号
OpenTiny 也在持续招募贡献者,欢迎一起共建
OpenTiny 官网:https://opentiny.design/
OpenTiny 代码仓库:https://github.com/opentiny/
TinyVue 源码:https://github.com/opentiny/tiny-vue
TinyEngine 源码: https://github.com/opentiny/tiny-engine
欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI~
如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~