WebWorkerで隔離されたJS実行環境を作ってみました

# TL;DR

<p class="codepen" data-height="862" data-default-tab="result" data-slug-hash="VYZwyzr" data-pen-title="Untitled" data-user="jerrywdlee" style="height: 862px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/jerrywdlee/pen/VYZwyzr">
  Untitled</a> by Julia Lee (<a href="https://codepen.io/jerrywdlee">@jerrywdlee</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>


# 本文
## はじめに

最近、とあるイベントで、[Blockly](https://developers.google.com/blockly)を使って、[Scratch](https://scratch.mit.edu/)ライクなコーディングサイトを作っているのを見た。
当時考えているのは、Block言語ならまだしも、サイト上にコード直書きの環境を提供しようとする場合、どうやってHostのページに対する改ざんを排除しつづ、ホストページの一部の機能を利用できるようにできるのか。

考えられる方法はいくつかあります。
1. サーバーにコードを送信し、サーバー上のVM環境で実行する(LeetCodeなどのやり方)
    - メリット: ほぼすべての言語が使える、Hostページが改ざんされる可能性はとても低い
    - デメリット: サーバー代がかかる、実装が複雑
2. [Fengari](https://github.com/fengari-lua/fengari)などJSのLua VMを使って実行環境を提供する
    - メリット: サーバーが要らない、実装が簡単、Hostページが改ざんされる可能性は低い
    - デメリット: 利用者のLuaの知識が必要、Fengariの開発は2019年以降止まっている 
3. ダミーの`<iFrame>`を作成して環境を提供する
    - メリット: サーバーが要らない、実装が簡単
    - デメリット: Hostと通信できるように設定をすると、`window.parent`でHost環境が改ざんできるようになる
4. **【本文】WebWorkerで実行環境を提供する**
    - メリット: サーバーが要らない、実装がやや簡単、Hostと通信できる一方、改ざんを防げる
    - デメリット: Host側が提供する関数は全部非同期関数となる


## 方法

### WebWorkerの作成

```worker.js
const promises = {} // { uid: { resolve, reject } }　実行結果を返すためのPromiseを格納する
const timeout = 1000 // タイムアウト時間

self.addEventListener('message', async e => {
  const { type, uid, funcNames, funcStr, result, error } = e.data

  switch (type) {
    case 'allow':
      for (const funcName of funcNames) {
        self[funcName] = function (...args) {
          const uid = crypto.randomUUID() // 実行IDを生成
          // Hostにユーザーが実行したい関数名と引数をHostに送信
          self.postMessage({ type: 'execRequest', funcName, uid, args })

          return new Promise((resolve, reject) => {
            promises[uid] = { resolve, reject } // 実行結果を返すためのPromiseを格納

            // タイムアウト処理
            setTimeout(() => {
              reject('timeout')
              delete promises[uid]
            }, timeout);
          })
        }
      }
      break
    case 'ban':
      // 禁止する関数をnullにする
      for (const funcName of funcNames) {
        self[funcName] = null
      }
      break
    case 'eval':
      if (funcStr) {
        try {
          // ユーザーのコードを実行
          eval(`(async ()=>{${funcStr}})().catch(error=>{self.postMessage({ type: 'error', error })})`)
        } catch (error) {
          // SyntaxErrorなどが発生した際の対応
          self.postMessage({ type: 'error', error })
        }
      }
      break
    case 'execResponse':
      // Hostから実行結果を受信した場合
      if (!promises[uid]) return
      promises[uid].resolve(result)
      delete promises[uid]
      break
    case 'execError':
      // Hostからエラーを受信した場合
      if (!promises[uid]) return
      promises[uid].reject(error)
      delete promises[uid]
      break
  }
})
```

## Workerの初期化
```main.js
const worker = new Worker("worker.js");

// ユーザーに実行可能な関数を定義
const allowFunctions = {
  alert: txt => { alert(txt) },
  append: txt => { document.body.innerHTML += txt },
};

// ユーザーに実行不可の関数を定義
const banList = ['fetch', 'Worker', 'importScripts'];

const worker = new Worker('worker.js');

// 実行可能な関数名をワーカーに送信
worker.postMessage({
  type: 'allow',
  funcList: Object.keys(allowFunctions), // 関数名のリストだけでよい
})

// 実行不可の関数名をワーカーに送信
worker.postMessage({
  type: 'ban',
  funcList: banList,
})
```

## Workerから情報を受信

```main.js
// Workerからのメッセージを受信
worker.onmessage = e => {
  const { type, funcName, uid, args, error } = e.data;

  if (type === 'execRequest') {
    try {
      // Workerから受信された関数名を引数を添えて実行
      const result = allowFunctions[funcName](...args);
      // 実行結果をワーカーに送信
      worker.postMessage({ type: 'execResponse', uid, result });
    } catch (error) {
      console.error(error);
      // エラーをワーカーに送信
      worker.postMessage({ type: 'execError', uid, error });
    }
  }

  if (type === 'error') {
    // エラー処理
    console.error(error);
  }
}
```

## ユーザーのカスタムJSコードを実行させる

```main.js
worker.postMessage({
  type: 'eval',
  funcStr: `
    alert('Hello, World!'); // Hostのページにアラートを表示
    append('<h1>Hello, World!</h1>'); // HostのページのBodyにHTMLを追加
    fetch('https://example.com'); // fetchは実行不可なので、エラーが発生する
  `,
})
```

# おまけ
ブラウザーでウェブページを提供している以上、ブラウザーのコンソルなどで改ざんされるリスクは常に存在している。[同一オリジンポリシー](https://developer.mozilla.org/ja/docs/Web/Security/Same-origin_policy#%E7%95%B0%E3%81%AA%E3%82%8B%E3%82%AA%E3%83%AA%E3%82%B8%E3%83%B3%E3%81%B8%E3%81%AE%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%97%E3%83%88%E3%81%8B%E3%82%89%E3%81%AE_api_%E3%81%AB%E3%82%88%E3%82%8B%E3%82%A2%E3%82%AF%E3%82%BB%E3%82%B9)で改ざん防止できる他に、F12や右クリック禁止、`debugger`機能の活用などで、改ざんするコストを高くする方法があります。

```js
// 右クリック禁止
document.oncontextmenu = () => { return false; };

// F12キー禁止
document.onkeydown = (event) => {
  if (event.keyCode == 123) {
    event.keyCode = 0;
    event.returnValue = false;
    return false;
  }
};

// 開発者ツールを開くのを防ぐ
setInterval(() => {
  // debugger
  (() => {}).constructor('debugger')()
}, 100)
```

# 参考
- [Web workers without a separate Javascript file?](https://stackoverflow.com/a/19201292/27159826)