From 1119b9ee01c3c17cf276aeaa1a46cf4573a38039 Mon Sep 17 00:00:00 2001 From: fruitriin Date: Wed, 3 Jun 2026 12:46:44 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=E3=82=A8=E3=83=A9=E3=83=BC=E7=94=BB?= =?UTF-8?q?=E9=9D=A2=E3=81=A7=E6=93=8D=E4=BD=9C=E4=B8=8D=E8=83=BD=E3=81=AB?= =?UTF-8?q?=E3=81=AA=E3=82=8B=E3=81=93=E3=81=A8=E3=81=8C=E3=81=82=E3=82=8B?= =?UTF-8?q?=E3=81=AE=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + packages/frontend-embed/public/loader/boot.js | 64 ++++++--- packages/frontend/public/loader/boot.js | 136 ++++++++++++++---- 3 files changed, 156 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d084006562c..07c0714d2a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ ### Client - Fix: ビルドに失敗することがある問題を修正 +- Fix: 起動時に unhandled rejection が発生するとブートエラー画面が無応答になる問題を修正 ## 2026.5.3 diff --git a/packages/frontend-embed/public/loader/boot.js b/packages/frontend-embed/public/loader/boot.js index 9b3d27873be..5ff08c5d0a6 100644 --- a/packages/frontend-embed/public/loader/boot.js +++ b/packages/frontend-embed/public/loader/boot.js @@ -7,6 +7,12 @@ // ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので (async () => { + // renderError 自身が throw すると、それが unhandledrejection を再発火させて + // onunhandledrejection ハンドラから renderError が再呼び出しされる無限ループに陥り、 + // メインスレッドを焼き尽くしてエラー画面が操作不能になる。 + // それを防ぐための再入ガード。 + let renderErrorRunning = false; + window.onerror = (e) => { console.error(e); renderError('SOMETHING_HAPPENED'); @@ -80,38 +86,64 @@ document.head.appendChild(css); } + function escapeHtml(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + async function renderError(code) { + if (renderErrorRunning) return; + renderErrorRunning = true; + try { + await renderErrorImpl(code); + } catch (e) { + try { console.error('renderError failed', e); } catch { /* noop */ } + } finally { + renderErrorRunning = false; + } + } + + async function renderErrorImpl(code) { // Cannot set property 'innerHTML' of null を回避 if (document.readyState === 'loading') { await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve)); } let messages = null; - const bootloaderLocales = localStorage.getItem('bootloaderLocales'); - if (bootloaderLocales) { - messages = JSON.parse(bootloaderLocales); - } - if (!messages) { - // older version of misskey does not store bootloaderLocales, stores locale as a whole - const legacyLocale = localStorage.getItem('locale'); - if (legacyLocale) { - const parsed = JSON.parse(legacyLocale); - messages = { - ...(parsed._bootErrors ?? {}), - reload: parsed.reload, - }; + try { + const bootloaderLocales = localStorage.getItem('bootloaderLocales'); + if (bootloaderLocales) { + messages = JSON.parse(bootloaderLocales); } + } catch { /* localStorage / JSON.parse の失敗は無視 */ } + if (!messages) { + try { + // older version of misskey does not store bootloaderLocales, stores locale as a whole + const legacyLocale = localStorage.getItem('locale'); + if (legacyLocale) { + const parsed = JSON.parse(legacyLocale); + messages = { + ...(parsed._bootErrors ?? {}), + reload: parsed.reload, + }; + } + } catch { /* localStorage / JSON.parse の失敗は無視 */ } } if (!messages) messages = {}; const title = messages?.title || 'Failed to initialize Misskey'; const reload = messages?.reload || 'Reload'; + // 補間値は HTML エスケープ済み (XSS 対策) document.body.innerHTML = ` -
${title}
-
Error Code: ${code}
+
${escapeHtml(title)}
+
Error Code: ${escapeHtml(code)}
`; addStyle(` #misskey_app, diff --git a/packages/frontend/public/loader/boot.js b/packages/frontend/public/loader/boot.js index 8aafb282aa6..ffb3c643648 100644 --- a/packages/frontend/public/loader/boot.js +++ b/packages/frontend/public/loader/boot.js @@ -7,13 +7,21 @@ // ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので (async () => { + // renderError 自身が throw すると、それが unhandledrejection を再発火させて + // onunhandledrejection ハンドラから renderError が再呼び出しされる無限ループに陥り、 + // メインスレッドを焼き尽くしてエラー画面が操作不能になる。 + // それを防ぐための再入ガード。 + let renderErrorRunning = false; + window.onerror = (e) => { console.error(e); renderError('SOMETHING_HAPPENED', e); }; window.onunhandledrejection = (e) => { console.error(e); - renderError('SOMETHING_HAPPENED_IN_PROMISE', e.reason || e); + // e.reason は falsy 値 (0, '', null 等) で reject される可能性があるため + // `||` ではなく in 演算子で存在確認する + renderError('SOMETHING_HAPPENED_IN_PROMISE', 'reason' in e ? e.reason : e); }; let forceError = localStorage.getItem('forceError'); @@ -127,27 +135,92 @@ document.head.appendChild(css); } + function escapeHtml(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function safeToString(v) { + try { + if (v == null) return String(v); + if (typeof v.toString === 'function') { + const s = v.toString(); + if (typeof s === 'string') return s; + } + // Object.create(null) 等 toString を持たないオブジェクト向けの最終フォールバック + return Object.prototype.toString.call(v); + } catch { + return '[unserializable]'; + } + } + + function safeJsonStringify(v) { + // 兄弟関係で同じ参照が現れる (`{a: x, b: x}`) ケースを循環と誤検出しないよう、 + // 祖先パスのみを追跡する。replacer 内の this はその値を保持する親オブジェクトを指す + // 仕様 (https://tc39.es/ecma262/#sec-json.stringify) を利用して、今回の親より下の + // 祖先をスタックから巻き戻してから判定する。 + const ancestors = []; + try { + return JSON.stringify(v, function (_key, value) { + if (typeof value === 'bigint') return value.toString() + 'n'; + if (typeof value !== 'object' || value === null) return value; + while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) { + ancestors.pop(); + } + if (ancestors.includes(value)) return '[Circular]'; + ancestors.push(value); + return value; + }) ?? ''; + } catch { + return ''; + } + } + async function renderError(code, details) { + if (renderErrorRunning) return; + renderErrorRunning = true; + try { + await renderErrorImpl(code, details); + } catch (e) { + try { console.error('renderError failed', e); } catch { /* noop */ } + } finally { + renderErrorRunning = false; + } + } + + async function renderErrorImpl(code, details) { // Cannot set property 'innerHTML' of null を回避 if (document.readyState === 'loading') { await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve)); } + // 既にエラー画面が描画済みなら、同じ id を二重に挿入しないよう抜ける + // (連発するエラーは console.error 済み) + if (document.getElementById('errorInfo')) return; + let messages = null; - const bootloaderLocales = localStorage.getItem('bootloaderLocales'); - if (bootloaderLocales) { - messages = JSON.parse(bootloaderLocales); - } - if (!messages) { - // older version of misskey does not store bootloaderLocales, stores locale as a whole - const legacyLocale = localStorage.getItem('locale'); - if (legacyLocale) { - const parsed = JSON.parse(legacyLocale); - messages = { - ...(parsed._bootErrors ?? {}), - reload: parsed.reload, - }; + try { + const bootloaderLocales = localStorage.getItem('bootloaderLocales'); + if (bootloaderLocales) { + messages = JSON.parse(bootloaderLocales); } + } catch { /* localStorage / JSON.parse の失敗は無視 */ } + if (!messages) { + try { + // older version of misskey does not store bootloaderLocales, stores locale as a whole + const legacyLocale = localStorage.getItem('locale'); + if (legacyLocale) { + const parsed = JSON.parse(legacyLocale); + messages = { + ...(parsed._bootErrors ?? {}), + reload: parsed.reload, + }; + } + } catch { /* localStorage / JSON.parse の失敗は無視 */ } } if (!messages) messages = {}; @@ -172,44 +245,47 @@ let errorsElement = document.getElementById('errors'); if (!errorsElement) { + // 補間値は HTML エスケープ済み (XSS 対策)。 + // messages は localStorage 由来で origin scoped だが、frontend-embed 側と + // 防御方針を揃えるためここでも一律エスケープする。 document.body.innerHTML = ` -

${messages.title}

+

${escapeHtml(messages.title)}

-

${messages.solution}

-

${messages.solution1}

-

${messages.solution2}

-

${messages.solution3}

-

${messages.solution4}

+

${escapeHtml(messages.solution)}

+

${escapeHtml(messages.solution1)}

+

${escapeHtml(messages.solution2)}

+

${escapeHtml(messages.solution3)}

+

${escapeHtml(messages.solution4)}

- ${messages.otherOption} - + ${escapeHtml(messages.otherOption)} +


@@ -220,12 +296,14 @@ } const detailsElement = document.createElement('details'); detailsElement.id = 'errorInfo'; + // details は循環参照や toString 不在、BigInt 等で throw する可能性があるため安全化する + // (XSS 対策として HTML エスケープも行う) detailsElement.innerHTML = `
- ERROR CODE: ${code} + ERROR CODE: ${escapeHtml(code)} - ${details.toString()} ${JSON.stringify(details)}`; + ${escapeHtml(safeToString(details))} ${escapeHtml(safeJsonStringify(details))}`; errorsElement.appendChild(detailsElement); addStyle(` * { From 3bbf15670ec461854d5bf7880f777193913547e9 Mon Sep 17 00:00:00 2001 From: fruitriin Date: Wed, 3 Jun 2026 13:23:44 +0900 Subject: [PATCH 2/4] fix changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07c0714d2a2..e4e64a3dd0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - Fix: 一部のUI要素の色が正しく表示されない問題を修正 (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/1243) - Fix: 「D」キーでダークモードを切り替える際にsyncDeviceDarkModeのチェックがバイパスされる問題を修正 +- Fix: 起動時に unhandled rejection が発生するとブートエラー画面が無応答になる問題を修正 ### Server - Enhance: リモートノートクリーニングジョブのスキップ処理のパフォーマンス改善 @@ -29,7 +30,6 @@ ### Client - Fix: ビルドに失敗することがある問題を修正 -- Fix: 起動時に unhandled rejection が発生するとブートエラー画面が無応答になる問題を修正 ## 2026.5.3 From 2ed45b8db0c46f5db688d2530fea5298ae950adb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=9C=E7=89=A9=E3=83=AA=E3=83=B3?= Date: Wed, 3 Jun 2026 15:17:05 +0900 Subject: [PATCH 3/4] Apply suggestion from @kakkokari-gtyih MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4e64a3dd0e..4cbbe61d987 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ - Fix: 一部のUI要素の色が正しく表示されない問題を修正 (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/1243) - Fix: 「D」キーでダークモードを切り替える際にsyncDeviceDarkModeのチェックがバイパスされる問題を修正 -- Fix: 起動時に unhandled rejection が発生するとブートエラー画面が無応答になる問題を修正 +- Fix: エラー画面で操作不能になることがあるのを修正 ### Server - Enhance: リモートノートクリーニングジョブのスキップ処理のパフォーマンス改善 From 51039d8efd59dcc0c4d044ec1f4a46bca50d96b8 Mon Sep 17 00:00:00 2001 From: fruitriin Date: Wed, 3 Jun 2026 15:34:10 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix(frontend):=20=E3=82=A8=E3=83=A9?= =?UTF-8?q?=E3=83=BC=E7=94=BB=E9=9D=A2=E3=81=AEHTML=E3=82=A8=E3=82=B9?= =?UTF-8?q?=E3=82=B1=E3=83=BC=E3=83=97=E4=BF=AE=E6=AD=A3=E3=82=92PR?= =?UTF-8?q?=E3=82=B9=E3=82=B3=E3=83=BC=E3=83=97=E3=81=8B=E3=82=89=E9=99=A4?= =?UTF-8?q?=E5=A4=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XSS対策のHTMLエスケープはこのPRの本来のスコープ (エラー画面の操作不能修正) 外の 変更だったため、別ブランチ (error-page-escape) に分離する。 --- packages/frontend-embed/public/loader/boot.js | 16 +--- packages/frontend/public/loader/boot.js | 80 ++++--------------- 2 files changed, 18 insertions(+), 78 deletions(-) diff --git a/packages/frontend-embed/public/loader/boot.js b/packages/frontend-embed/public/loader/boot.js index 5ff08c5d0a6..86beaf26a5b 100644 --- a/packages/frontend-embed/public/loader/boot.js +++ b/packages/frontend-embed/public/loader/boot.js @@ -86,15 +86,6 @@ document.head.appendChild(css); } - function escapeHtml(s) { - return String(s) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - } - async function renderError(code) { if (renderErrorRunning) return; renderErrorRunning = true; @@ -138,12 +129,11 @@ const title = messages?.title || 'Failed to initialize Misskey'; const reload = messages?.reload || 'Reload'; - // 補間値は HTML エスケープ済み (XSS 対策) document.body.innerHTML = ` -
${escapeHtml(title)}
-
Error Code: ${escapeHtml(code)}
+
${title}
+
Error Code: ${code}
`; addStyle(` #misskey_app, diff --git a/packages/frontend/public/loader/boot.js b/packages/frontend/public/loader/boot.js index ffb3c643648..5d46f6b0375 100644 --- a/packages/frontend/public/loader/boot.js +++ b/packages/frontend/public/loader/boot.js @@ -135,51 +135,6 @@ document.head.appendChild(css); } - function escapeHtml(s) { - return String(s) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - } - - function safeToString(v) { - try { - if (v == null) return String(v); - if (typeof v.toString === 'function') { - const s = v.toString(); - if (typeof s === 'string') return s; - } - // Object.create(null) 等 toString を持たないオブジェクト向けの最終フォールバック - return Object.prototype.toString.call(v); - } catch { - return '[unserializable]'; - } - } - - function safeJsonStringify(v) { - // 兄弟関係で同じ参照が現れる (`{a: x, b: x}`) ケースを循環と誤検出しないよう、 - // 祖先パスのみを追跡する。replacer 内の this はその値を保持する親オブジェクトを指す - // 仕様 (https://tc39.es/ecma262/#sec-json.stringify) を利用して、今回の親より下の - // 祖先をスタックから巻き戻してから判定する。 - const ancestors = []; - try { - return JSON.stringify(v, function (_key, value) { - if (typeof value === 'bigint') return value.toString() + 'n'; - if (typeof value !== 'object' || value === null) return value; - while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) { - ancestors.pop(); - } - if (ancestors.includes(value)) return '[Circular]'; - ancestors.push(value); - return value; - }) ?? ''; - } catch { - return ''; - } - } - async function renderError(code, details) { if (renderErrorRunning) return; renderErrorRunning = true; @@ -245,47 +200,44 @@ let errorsElement = document.getElementById('errors'); if (!errorsElement) { - // 補間値は HTML エスケープ済み (XSS 対策)。 - // messages は localStorage 由来で origin scoped だが、frontend-embed 側と - // 防御方針を揃えるためここでも一律エスケープする。 document.body.innerHTML = ` -

${escapeHtml(messages.title)}

+

${messages.title}

-

${escapeHtml(messages.solution)}

-

${escapeHtml(messages.solution1)}

-

${escapeHtml(messages.solution2)}

-

${escapeHtml(messages.solution3)}

-

${escapeHtml(messages.solution4)}

+

${messages.solution}

+

${messages.solution1}

+

${messages.solution2}

+

${messages.solution3}

+

${messages.solution4}

- ${escapeHtml(messages.otherOption)} - + ${messages.otherOption} +


@@ -296,14 +248,12 @@ } const detailsElement = document.createElement('details'); detailsElement.id = 'errorInfo'; - // details は循環参照や toString 不在、BigInt 等で throw する可能性があるため安全化する - // (XSS 対策として HTML エスケープも行う) detailsElement.innerHTML = `
- ERROR CODE: ${escapeHtml(code)} + ERROR CODE: ${code} - ${escapeHtml(safeToString(details))} ${escapeHtml(safeJsonStringify(details))}`; + ${details.toString()} ${JSON.stringify(details)}`; errorsElement.appendChild(detailsElement); addStyle(` * {