TL;DR
See the Pen Untitled by Julia Lee (@jerrywdlee) on CodePen.
本文
はじめに
最近、とあるイベントで、Blocklyを使って、Scratchライクなコーディングサイトを作っているのを見た。
当時考えているのは、Block言語ならまだしも、サイト上にコード直書きの環境を提供しようとする場合、どうやってHostのページに対する改ざんを排除しつづ、ホストページの一部の機能を利用できるようにできるのか。
考えられる方法はいくつかあります。
- サーバーにコードを送信し、サーバー上のVM環境で実行する(LeetCodeなどのやり方)
- メリット: ほぼすべての言語が使える、Hostページが改ざんされる可能性はとても低い
- デメリット: サーバー代がかかる、実装が複雑
-
FengariなどJSのLua VMを使って実行環境を提供する
- メリット: サーバーが要らない、実装が簡単、Hostページが改ざんされる可能性は低い
- デメリット: 利用者のLuaの知識が必要、Fengariの開発は2019年以降止まっている
- ダミーの
<iFrame>
を作成して環境を提供する- メリット: サーバーが要らない、実装が簡単
- デメリット: Hostと通信できるように設定をすると、
window.parent
でHost環境が改ざんできるようになる
-
【本文】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)