mirror of
https://github.com/Silentely/eSIM-Tools.git
synced 2026-09-03 06:24:20 +08:00
🐛 fix: 修复二维码生成 Promise 挂起、XSS 风险和 i18n 硬编码问题
- 修复 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
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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 = `
|
||||
<div class="alert alert-danger">
|
||||
<i class="fas fa-exclamation-circle me-2"></i>
|
||||
${t('giffgaff.app.qr.failed')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 使用 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ function validateQRCodeData(data) {
|
||||
/**
|
||||
* 懒加载浏览器端 qrcode.js 库。
|
||||
* 首次调用会插入 CDN script,后续调用复用同一个 Promise,避免重复加载。
|
||||
* 修复 Promise 挂起问题:移除失效脚本、添加超时保护(Issue #75 根因修复)。
|
||||
* @returns {Promise<Object>} 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({
|
||||
|
||||
@@ -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 = `
|
||||
<div class="alert alert-danger">
|
||||
<i class="fas fa-exclamation-circle me-2"></i>
|
||||
${t('simyo.app.qr.failed')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 使用 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* qrcode-generate Function 单元测试
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user