In general, on an HTML page, you can always access the HTML elements you want to access through the DOM API and manipulate them.

If, for some reason, we allow the user to inject code into the web page, but we want to forbid the user to operate on the DOM object, that is, we only allow the user to call the API we provide, and we do not allow the user to modify the UI component we create or even the entire web content through the injected JS, then what do we do?

There is basically no way to use JS to prevent users from manipulating the DOM with injected JS, because objects such as window objects and document objects and their apis cannot be overwritten with JS:

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

If we use the Object getOwnPropertyDescriptor view, find the window. The document is actually a getter, and its configurable is false.

Object.getOwnPropertyDescriptor(window.'document');
// {get: ƒ, set: undefined, Enumerable: true, different: false}
Copy the code

Even if we scoped the JS functions that allowed the user to legally inject them, we couldn’t completely prevent the user from accessing the Document and window objects.

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

For example, in the code above, we add wrapping code before and after the user-injected code, overriding the window and document objects with function arguments. We can still get the Window object through the function call this.

To prevent this loophole, we can use strict mode when packaging:

(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)
Copy the code

But this still doesn’t solve the problem:

(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)
Copy the code

So the conclusion is that wrapping code does not make window and Document objects completely inaccessible to the user.

So how can we legally allow user-injected code to do this, but still isolate window and Document objects?

Sandbox with worker

The first method is to only allow the user’s code to run in the worker.

We know that worker environment is a thread independent from browser environment, so codes running in worker cannot access window and Document objects, thus ensuring security.

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);
Copy the code

The problem with using workers is that when workers communicate with the browser environment, postMessage needs to be adopted. If there are many interactive operations, the performance overhead will be high, and there will be usage cost for developers writing codes.

The use of Shadow DOM

If we just don’t allow user-injected JS to modify the UI, we can also render the entire UI through Shadow DOM and set ShadowRoot’s mode to Closed. In this way, users can’t get ShadowRoot objects. So you can’t operate.

Here’s a simple example:

(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'); Copy the code

    We through the document. The body. AttachShadow ({mode: ‘closed’}); Create ShadowRoot and use the Shadow DOM API to create UI. Since the root object is not exposed to users and the mode is closed, users cannot get the object and operate our UI through DOM. This can only be done through addTask and removeTask that we expose to the user.

    💡 Note that the user can of course still insert other content into and out of the body through the DOM API, but when an element creates a Shadow DOM, the browser will preferentially render the Shadow DOM and ignore its other child elements, so anything the user inserts into the body will not be rendered. The only exception is if a script tag is inserted, the script will be executed, but we can simply prevent the user from inserting a script tag by preventing XSS code filtering.

    In this way, we can obtain a relatively safe environment through Shadow DOM. Compared with the way of worker, we can avoid the cost of postMessage and have a simpler writing method. Of course, there are drawbacks to Shadow DOM. For example, users can no longer rewrite the rendered content of the body element, but they can delete the body element completely and create a new one:

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

    But then, everything in the original body would also need to be rebuilt. Therefore, at the very least, using the Shadow DOM API significantly increases the cost of user intrusion.

    This is the end of banning developers from manipulating DOM objects. If there are any other possible methods, please feel free to discuss them in the issue.


    Welcome to “Byte front end ByteFE”

    Resume mailing address: [email protected]