From 0152277b4dd85e13a8f7bfbcec737a7aedfb695a Mon Sep 17 00:00:00 2001 From: Abner <22141172+Silentely@users.noreply.github.com> Date: Sat, 13 Jun 2026 19:43:50 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=BA=8C=E7=BB=B4=E7=A0=81=E7=94=9F=E6=88=90=20Promise=20?= =?UTF-8?q?=E6=8C=82=E8=B5=B7=E3=80=81XSS=20=E9=A3=8E=E9=99=A9=E5=92=8C=20?= =?UTF-8?q?i18n=20=E7=A1=AC=E7=BC=96=E7=A0=81=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 loadQRCodeLibrary Promise 永久挂起(Issue #75 根因) - 检测已存在脚本标签,移除失效脚本并重新加载 - 添加 5 秒超时保护,防止 Session 恢复后二维码冻结 - 统一 cleanup 函数清理监听器和定时器 - 修复 XSS 风险 - 使用 DOM API 创建错误提示,避免 innerHTML 注入 - 移除模板字符串 ${t(...)} 直接注入 - 修复 i18n 硬编码 - createQRCodeContainer 接受 labels 参数支持多语言 - generateQRCode* 函数支持 labels 透传 - UIController 传入 tl() 翻译后的可访问性标签 - Schema 验证优化 - qrcode-generate 保留手动验证,确保 405 优先级 - 添加注释说明设计意图 - 代码规范 - tests/security/qrcode-generate.test.js 添加 'use strict' ✅ 测试: 8 passed, 0 failed --- netlify/functions/qrcode-generate.js | 5 +- src/giffgaff/js/modules/ui-controller.js | 28 ++++++-- src/js/modules/qrcode-generator.js | 89 +++++++++++++++++++----- src/simyo/js/modules/ui-controller.js | 28 ++++++-- tests/security/qrcode-generate.test.js | 2 + 5 files changed, 118 insertions(+), 34 deletions(-) diff --git a/netlify/functions/qrcode-generate.js b/netlify/functions/qrcode-generate.js index ba7c9a6..79aafbe 100644 --- a/netlify/functions/qrcode-generate.js +++ b/netlify/functions/qrcode-generate.js @@ -47,11 +47,12 @@ function withTimeout(promise, timeoutMs) { } exports.handler = withAuth(async (event, context, { body }) => { + // 405 优先于 Schema 验证(确保 HTTP 方法错误优先返回) if (event.httpMethod !== 'POST') { throw new AuthError('Method Not Allowed', 405); } - // withAuth 不在这里使用 validateSchema,确保非 POST 请求先返回 405。 + // 手动调用 Schema 验证,确保 405 已经检查过 validateInput(qrcodeSchema, body); const size = normalizeSize(body.size); @@ -76,4 +77,4 @@ exports.handler = withAuth(async (event, context, { body }) => { console.error('[qrcode-generate] QR code generation failed:', error.message); throw error; } -}); +}, { requireAuth: true }); diff --git a/src/giffgaff/js/modules/ui-controller.js b/src/giffgaff/js/modules/ui-controller.js index 7c67cfd..8f645b2 100644 --- a/src/giffgaff/js/modules/ui-controller.js +++ b/src/giffgaff/js/modules/ui-controller.js @@ -431,7 +431,13 @@ export class UIController { const gen = ++this._qrGeneration; try { - const result = await generateQRCodeWithFallback(data, size); + const labels = { + alt: tl('eSIM 二维码'), + ariaLabel: tl('eSIM 安装二维码'), + tooltipAlt: tl('eSIM 二维码放大预览') + }; + + const result = await generateQRCodeWithFallback(data, size, labels); if (gen !== this._qrGeneration) return; // 防止并发调用干扰 if (result.tooltip && typeof this.showTooltipElement === 'function' && typeof this.hideTooltipElement === 'function') { @@ -444,12 +450,20 @@ export class UIController { } catch (error) { if (gen !== this._qrGeneration) return; console.error('[Giffgaff] QR code generation failed:', error); - this.elements.qrcode.innerHTML = ` -
- - ${t('giffgaff.app.qr.failed')} -
- `; + + // 使用 DOM API 创建元素,避免 innerHTML XSS 风险 + const alertDiv = document.createElement('div'); + alertDiv.className = 'alert alert-danger'; + + const icon = document.createElement('i'); + icon.className = 'fas fa-exclamation-circle me-2'; + alertDiv.appendChild(icon); + + const message = document.createTextNode(t('giffgaff.app.qr.failed')); + alertDiv.appendChild(message); + + this.elements.qrcode.innerHTML = ''; + this.elements.qrcode.appendChild(alertDiv); } } diff --git a/src/js/modules/qrcode-generator.js b/src/js/modules/qrcode-generator.js index b19035d..8f93b84 100644 --- a/src/js/modules/qrcode-generator.js +++ b/src/js/modules/qrcode-generator.js @@ -100,6 +100,7 @@ function validateQRCodeData(data) { /** * 懒加载浏览器端 qrcode.js 库。 * 首次调用会插入 CDN script,后续调用复用同一个 Promise,避免重复加载。 + * 修复 Promise 挂起问题:移除失效脚本、添加超时保护(Issue #75 根因修复)。 * @returns {Promise} qrcode.js 暴露的 QRCode 对象 */ export async function loadQRCodeLibrary() { @@ -116,32 +117,71 @@ export async function loadQRCodeLibrary() { } qrCodeLibraryPromise = new Promise((resolve, reject) => { + const LOAD_TIMEOUT_MS = 5000; + let timeoutId = null; + const existingScript = document.querySelector(`script[src="${QRCODE_CDN_URL}"]`); - const script = existingScript || document.createElement('script'); + + // 如果脚本已存在且 window.QRCode 已加载,直接返回 + if (existingScript && window.QRCode) { + resolve(window.QRCode); + return; + } + + // 如果脚本已存在但 window.QRCode 未加载(说明脚本已失效),移除并重新加载 + if (existingScript) { + console.warn('[QRCode] Existing script found but QRCode global is missing, removing stale script'); + existingScript.remove(); + } + + // 创建新的脚本元素 + const script = document.createElement('script'); + + const cleanup = () => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + script.removeEventListener('load', handleLoad); + script.removeEventListener('error', handleError); + }; const handleLoad = () => { + cleanup(); if (window.QRCode) { resolve(window.QRCode); return; } + // 脚本加载成功但 window.QRCode 不存在(CDN 内容异常) qrCodeLibraryPromise = null; + script.remove(); reject(new Error('QRCode library loaded but QRCode global is missing')); }; const handleError = () => { + cleanup(); qrCodeLibraryPromise = null; + script.remove(); reject(new Error('Failed to load QRCode library from CDN')); }; + const handleTimeout = () => { + cleanup(); + qrCodeLibraryPromise = null; + script.remove(); + reject(new Error(`QRCode library loading timed out after ${LOAD_TIMEOUT_MS}ms`)); + }; + script.addEventListener('load', handleLoad, { once: true }); script.addEventListener('error', handleError, { once: true }); - if (!existingScript) { - script.src = QRCODE_CDN_URL; - script.async = true; - script.crossOrigin = 'anonymous'; - document.head.appendChild(script); - } + // 添加超时保护,防止 Promise 永久挂起 + timeoutId = setTimeout(handleTimeout, LOAD_TIMEOUT_MS); + + script.src = QRCODE_CDN_URL; + script.async = true; + script.crossOrigin = 'anonymous'; + document.head.appendChild(script); }); return qrCodeLibraryPromise; @@ -153,9 +193,19 @@ export async function loadQRCodeLibrary() { * @param {string} imageUrl 二维码图片 data URL * @param {number} size 二维码显示尺寸 * @param {string} [largeImageUrl=imageUrl] tooltip 使用的大图 data URL + * @param {Object} [labels={}] 可访问性标签(支持 i18n) + * @param {string} [labels.alt='eSIM QR Code'] 图片 alt 文本 + * @param {string} [labels.ariaLabel='eSIM Installation QR Code'] 图片 aria-label + * @param {string} [labels.tooltipAlt='eSIM QR Code Preview'] tooltip 大图 alt 文本 * @returns {{container: HTMLElement, image: HTMLImageElement, tooltip: HTMLElement}} */ -function createQRCodeContainer(imageUrl, size, largeImageUrl = imageUrl) { +function createQRCodeContainer(imageUrl, size, largeImageUrl = imageUrl, labels = {}) { + const { + alt = 'eSIM QR Code', + ariaLabel = 'eSIM Installation QR Code', + tooltipAlt = 'eSIM QR Code Preview' + } = labels; + const container = document.createElement('div'); container.className = 'qrcode-container'; container.style.position = 'relative'; @@ -163,10 +213,10 @@ function createQRCodeContainer(imageUrl, size, largeImageUrl = imageUrl) { const image = document.createElement('img'); image.src = imageUrl; - image.alt = 'eSIM二维码'; + image.alt = alt; image.className = 'img-fluid'; image.setAttribute('role', 'img'); - image.setAttribute('aria-label', 'eSIM 安装二维码'); + image.setAttribute('aria-label', ariaLabel); image.setAttribute('loading', 'lazy'); image.style.border = '5px solid white'; image.style.borderRadius = '12px'; @@ -182,7 +232,7 @@ function createQRCodeContainer(imageUrl, size, largeImageUrl = imageUrl) { const largeImage = document.createElement('img'); largeImage.src = largeImageUrl; - largeImage.alt = 'eSIM二维码放大预览'; + largeImage.alt = tooltipAlt; largeImage.style.width = '400px'; largeImage.style.height = '400px'; tooltip.appendChild(largeImage); @@ -197,9 +247,10 @@ function createQRCodeContainer(imageUrl, size, largeImageUrl = imageUrl) { * 使用浏览器本地 qrcode.js 生成二维码。 * @param {string} data 二维码内容 * @param {number} [size=300] 二维码尺寸 + * @param {Object} [labels={}] 可访问性标签(支持 i18n) * @returns {Promise<{container: HTMLElement, image: HTMLImageElement, tooltip: HTMLElement, source: string}>} */ -export async function generateQRCodeLocal(data, size = DEFAULT_QR_SIZE) { +export async function generateQRCodeLocal(data, size = DEFAULT_QR_SIZE, labels = {}) { try { const safeData = validateQRCodeData(data); const safeSize = normalizeQRCodeSize(size); @@ -220,7 +271,7 @@ export async function generateQRCodeLocal(data, size = DEFAULT_QR_SIZE) { }); return { - ...createQRCodeContainer(imageUrl, safeSize, largeImageUrl), + ...createQRCodeContainer(imageUrl, safeSize, largeImageUrl, labels), source: 'local' }; } catch (error) { @@ -232,9 +283,10 @@ export async function generateQRCodeLocal(data, size = DEFAULT_QR_SIZE) { * 调用后端 Function 生成二维码,作为浏览器本地生成失败时的降级方案。 * @param {string} data 二维码内容 * @param {number} [size=300] 二维码尺寸 + * @param {Object} [labels={}] 可访问性标签(支持 i18n) * @returns {Promise<{container: HTMLElement, image: HTMLImageElement, tooltip: HTMLElement, source: string}>} */ -export async function generateQRCodeBackend(data, size = DEFAULT_QR_SIZE) { +export async function generateQRCodeBackend(data, size = DEFAULT_QR_SIZE, labels = {}) { const safeData = validateQRCodeData(data); const safeSize = normalizeQRCodeSize(size); const controller = new AbortController(); @@ -260,7 +312,7 @@ export async function generateQRCodeBackend(data, size = DEFAULT_QR_SIZE) { } return { - ...createQRCodeContainer(payload.qrcode, safeSize), + ...createQRCodeContainer(payload.qrcode, safeSize, payload.qrcode, labels), source: 'backend' }; } catch (error) { @@ -277,15 +329,16 @@ export async function generateQRCodeBackend(data, size = DEFAULT_QR_SIZE) { * 三层降级二维码生成:本地生成 → 后端生成 → 抛错由调用方显示 LPA 手动安装提示。 * @param {string} data 二维码内容 * @param {number} [size=300] 二维码尺寸 + * @param {Object} [labels={}] 可访问性标签(支持 i18n) * @returns {Promise<{container: HTMLElement, image: HTMLImageElement, tooltip: HTMLElement, source: string}>} */ -export async function generateQRCodeWithFallback(data, size = DEFAULT_QR_SIZE) { +export async function generateQRCodeWithFallback(data, size = DEFAULT_QR_SIZE, labels = {}) { const startTime = Date.now(); let localError = null; // 尝试本地生成 try { - const result = await generateQRCodeLocal(data, size); + const result = await generateQRCodeLocal(data, size, labels); const duration = Date.now() - startTime; trackQRCodeEvent({ @@ -311,7 +364,7 @@ export async function generateQRCodeWithFallback(data, size = DEFAULT_QR_SIZE) { // 本地失败,尝试后端降级 try { - const result = await generateQRCodeBackend(data, size); + const result = await generateQRCodeBackend(data, size, labels); const duration = Date.now() - startTime; trackQRCodeEvent({ diff --git a/src/simyo/js/modules/ui-controller.js b/src/simyo/js/modules/ui-controller.js index 6734151..c0e3211 100644 --- a/src/simyo/js/modules/ui-controller.js +++ b/src/simyo/js/modules/ui-controller.js @@ -351,7 +351,13 @@ export class UIController { const gen = ++this._qrGeneration; try { - const result = await generateQRCodeWithFallback(data, size); + const labels = { + alt: tl('eSIM 二维码'), + ariaLabel: tl('eSIM 安装二维码'), + tooltipAlt: tl('eSIM 二维码放大预览') + }; + + const result = await generateQRCodeWithFallback(data, size, labels); if (gen !== this._qrGeneration) return; // 防止并发调用干扰 if (result.tooltip && typeof this.showTooltipElement === 'function' && typeof this.hideTooltipElement === 'function') { @@ -364,12 +370,20 @@ export class UIController { } catch (error) { if (gen !== this._qrGeneration) return; console.error('[Simyo] QR code generation failed:', error); - this.elements.qrcode.innerHTML = ` -
- - ${t('simyo.app.qr.failed')} -
- `; + + // 使用 DOM API 创建元素,避免 innerHTML XSS 风险 + const alertDiv = document.createElement('div'); + alertDiv.className = 'alert alert-danger'; + + const icon = document.createElement('i'); + icon.className = 'fas fa-exclamation-circle me-2'; + alertDiv.appendChild(icon); + + const message = document.createTextNode(t('simyo.app.qr.failed')); + alertDiv.appendChild(message); + + this.elements.qrcode.innerHTML = ''; + this.elements.qrcode.appendChild(alertDiv); } } diff --git a/tests/security/qrcode-generate.test.js b/tests/security/qrcode-generate.test.js index 01bad76..7a6e117 100644 --- a/tests/security/qrcode-generate.test.js +++ b/tests/security/qrcode-generate.test.js @@ -1,3 +1,5 @@ +'use strict'; + /** * qrcode-generate Function 单元测试 */