akira-cn/FE_You_dont_know

如何禁止开发者操作网页上的DOM对象?

akira-cn opened this issue · 6 comments

通常情况下,在一个HTML页面上,我们总能够通过DOM API访问想要访问的HTML元素,进行操作。

如果基于某种原因,允许用户注入代码到网页上,但又要禁止用户对DOM对象进行操作,即只允许用户调用我们提供的API,不允许用户通过注入的JS来修改我们创建的UI组件甚至整个网页内容,那么我们要怎么做呢?

我们基本上无法通过JS来禁止用户通过注入的JS操作DOM,因为window对象、document对象这些对象以及它们的API是无法通过JS改写的:

window.document = 111;
console.log(window.document); // #document

如果我们用Object.getOwnPropertyDescriptor查看,会发现window.document实际上是一个getter,而且它的configurable是false。

Object.getOwnPropertyDescriptor(window, 'document');
// {get: ƒ, set: undefined, enumerable: true, configurable: false}

即使我们对允许用户合法注入的JS外面包裹函数作用域,我们还是无法彻底阻止用户访问document和window对象。

(function(window, document) {
  // user code
  console.log(this, window, document); // {}, null, null
  const win = (function () {
    return this;
  }());
  console.log(win); // Window
  // ---
}).call({}, null, null)

比如说上面的代码,我们在用户注入的代码前后增加包装代码,把window和document对象通过函数参数覆盖,我们还是能通过函数调用的this拿到window对象。

要防住这个漏洞,我们可以在包装的时候使用严格模式:

(function(window, document) {'use strict'
  // user code
  console.log(this, window, document); // {}, null, null
  const win = (function () {
    return this;
  }());
  console.log(win); // undefined
  // ---
}).call({}, null, null)

但是这个依然不解决问题:

(function(window, document) {'use strict'
  console.log(this, window, document); // {}, null, null
  const win = (function () {
    return this;
  }());
  console.log(win);   // undefined
  setTimeout(function() {
    console.log(this);  // Window
  });
}).call({}, null, null)

所以结论是包装代码无法让用户彻底无法访问window和document对象。

那么我们要怎么做既能合法让用户注入代码实现功能,又能够隔离window和document对象呢?

用worker做沙箱

第一种办法是只允许用户的代码跑在worker里。

我们知道worker的环境是和浏览器环境互相独立的线程,所以跑在worker里的代码是不能访问window和document对象的,这样就保证了安全性。

function execCodeInWorker(code) {
  const blob = new Blob([code]);
  const url = URL.createObjectURL(blob);
  
  const worker = new Worker(url);
  return worker;
}

const userCode = `
  console.log(typeof window, typeof document); // undefined undefined
`;

execCodeInWorker(userCode);

使用worker的问题是,当worker和浏览器环境通讯时,需要采用postMessage,如果有较多的交互操作,性能开销比较大,而且对写代码的开发者有使用成本。

使用Shadow DOM

如果我们只是不允许用户注入的JS修改UI,我们也可以将整个UI通过Shadow DOM渲染,并且将ShadowRoot的模式设置为closed封闭起来,这样的话,用户就无法拿到Shadow DOM的ShadowRoot对象,从而无法进行操作。

下面是一个简单的例子:

(function () {
  const root = document.body.attachShadow({mode: 'closed'});
  
  let list;

  function init() {
    root.innerHTML = `
      <h1>Todo List</h1>
      <ul></ul>
    `
    list = root.querySelector('ul');
  }
  
  function addTask(desc) {
    const task = document.createElement('li');
    task.textContent = desc;
    list.appendChild(task);
    return list.children.length - 1;
  }
  
  function removeTask(index) {
    const task = list.children[index];
    if(task) task.remove();
  }
  
  window.init = init;
  window.addTask = addTask;
  window.removeTask = removeTask;
}());

init();
addTask('task1');

我们通过document.body.attachShadow({mode: 'closed'});创建ShadowRoot,通过Shadow DOM API来创建UI,因为这个root对象我们没有暴露给用户,而且mode是closed,所以用户拿不到对象,无法通过DOM操作我们的UI,只能通过我们暴露给用户的addTask和removeTask来操作。

💡注意,用户当然仍可以通过DOM API来往body中插入其他内容,但是,当一个元素创建了Shadow DOM,浏览器会优先渲染Shadow DOM,而忽略它的其他子元素,所以用户往body中插入任何内容都不会被渲染出来。唯一的例外是如果插入script标签,脚本会被执行,但是我们可以简单通过防止xss的代码过滤来阻止用户插入script标签。

这样,我们就通过Shadow DOM获得了一个相对安全的环境,对比worker的方式,可以避免postMessage的开销和拥有更简单的写法。当然Shadow DOM也有弊端,比如用户虽然不能改写当前body元素中渲染的内容了,但是可以彻底删掉body元素重新创建一个:

document.documentElement.removeChild(body);
const newBody = document.createElement('body');
document.documentElement.appendChild(newBody);

但是那样的话,原body中的所有内容也需要重建了。因此,使用Shadow DOM API至少大大增加了用户侵入的成本。

关于禁止开发者操作DOM对象的话题就谈到这里,还有什么可行的方法或者大家有什么想补充的,欢迎在issue中讨论。

libmw commented

如果要完全创建一个只可以访问指定对象的沙盒,月影有啥好办法没有呢?我们现在的做法是使用了一个脚本解析器 https://github.com/NeilFraser/JS-Interpreter

shadow dom 的兼容性不是很好 您那个worker方法我没有看的太明白? 怎么让用户的脚本 强制都套用那个 "execCodeInWorker"

libmw commented

shadow dom 的兼容性不是很好 您那个worker方法我没有看的太明白? 怎么让用户的脚本 强制都套用那个 "execCodeInWorker"

用户脚本是保存在userCode变量里的,execCodeInWorker是你自己的代码而不是用户写的

@libmw 用脚本解析器是可以的,一般也就是选择使用脚本解析器,就是有额外的开销(时间、空间)。
另外也可以使用目前的大部分小程序的做法,用双线程。

;(function () {
  // shadow global object, like window、document
  const shdowGlobals = []
  // valid context (合法上下文,即允许用户调用的API)
  const context = {}
  const contextVars = Object.keys(context).map((key) => `${key}=context.${key}`)

  const fn = new Function(
    'context',
    `
      var ${shdowGlobals.join(',')}
      var ${contextVars.join(',')}
      context = undefined
      ;(function() () {
        // user code (用户代码)
      }).call({})
      // prevent this by call (阻止通过 this 访问全局API)
    `
  )

  fn(context)
})()