1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JavaScriptAdvent Calendar 2024

Day 17

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

Last updated at Posted at 2024-12-16

TL;DR

See the Pen Untitled by Julia Lee (@jerrywdlee) on CodePen.

本文

はじめに

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

考えられる方法はいくつかあります。

  1. サーバーにコードを送信し、サーバー上のVM環境で実行する(LeetCodeなどのやり方)
    • メリット: ほぼすべての言語が使える、Hostページが改ざんされる可能性はとても低い
    • デメリット: サーバー代がかかる、実装が複雑
  2. 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は実行不可なので、エラーが発生する
  `,
})

おまけ

ブラウザーでウェブページを提供している以上、ブラウザーのコンソルなどで改ざんされるリスクは常に存在している。同一オリジンポリシーで改ざん防止できる他に、F12や右クリック禁止、debugger機能の活用などで、改ざんするコストを高くする方法があります。

// 右クリック禁止
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)

参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?