【前端冷知识】如何禁止开发者操作网页上的DOM对象?
通常情况下,在一个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 = `
Todo List
`
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来操作。
:bulb:注意,用户当然仍可以通过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中讨论。
关于奇舞周刊
《奇舞周刊》是360公司专业前端团队「 奇舞团 」运营的前端技术社区。关注公众号后,直接发送链接到后台即可给我们投稿。