fix(mp): 五专家组全面审计修复 — 安全+功能+UX+性能+代码质量
安全修复: - 移除硬编码管理员凭据 admin/Admin@2026,改用环境变量注入 - 移除 forceSetAuth 全局 bridge 方法,减少攻击面 - sanitizeHtml 从黑名单正则升级为白名单方式 - secure-storage 实现 XOR+Base64 加密存储,不再明文 - 添加旧数据迁移逻辑 migrateLegacyStorage 功能修复: - 新增咨询创建页(consultation/create),修复"发起咨询"按钮导航失败 - 修复咨询详情页长轮询可能永远不启动(dataLoadedRef → useState) - 新增 createSession service API - 预约页面从主包移至分包,配置 commonChunks 优化主包体积 UX 修复: - 65 处硬编码字号 → var(--tk-font-*) token 替换 - AI 聊天页 13 处、咨询详情页 14 处、医生端核心页 38 处 - StatusTag 色值对齐设计系统色板 - Loading 文字从 --tk-font-h1(28px) 修正为 --tk-font-body-sm - EmptyState 文字从 --tk-font-num(30px)/--tk-font-h2(22px) 修正 - 医生端 5 处硬编码颜色 → SCSS 变量
This commit is contained in:
@@ -1,7 +1,52 @@
|
||||
const DANGEROUS_TAG_RE = /<(?:script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>|\/?(?:iframe|object|embed|form|input|textarea|style|link|meta)\b[^>]*)>/gi;
|
||||
const DANGEROUS_ATTR_RE = /(?:\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)|(?:href|src)\s*=\s*(?:"(?:javascript|data):[^"]*"|'(?:javascript|data):[^']*'))/gi;
|
||||
const ALLOWED_TAGS = new Set([
|
||||
'p', 'br', 'hr', 'div', 'span',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'ul', 'ol', 'li',
|
||||
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
||||
'strong', 'em', 'b', 'i', 'u', 's', 'del', 'ins',
|
||||
'blockquote', 'pre', 'code',
|
||||
'a', 'img',
|
||||
'dl', 'dt', 'dd',
|
||||
'sup', 'sub',
|
||||
]);
|
||||
|
||||
const ALLOWED_ATTRS: Record<string, Set<string>> = {
|
||||
'*': new Set(['class']),
|
||||
a: new Set(['href', 'title']),
|
||||
img: new Set(['src', 'alt', 'width', 'height']),
|
||||
td: new Set(['colspan', 'rowspan']),
|
||||
th: new Set(['colspan', 'rowspan']),
|
||||
};
|
||||
|
||||
const URL_ATTRS = new Set(['href', 'src']);
|
||||
const SAFE_URL_RE = /^(?:https?|mailto|tel):|^$/i;
|
||||
|
||||
const TAG_RE = /<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*\/?>/g;
|
||||
const ATTR_RE = /([a-zA-Z][a-zA-Z0-9-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
||||
|
||||
export function sanitizeHtml(html: string): string {
|
||||
if (!html) return '';
|
||||
return html.replace(DANGEROUS_TAG_RE, '').replace(DANGEROUS_ATTR_RE, '');
|
||||
|
||||
return html.replace(TAG_RE, (fullMatch, tagName) => {
|
||||
const tag = tagName.toLowerCase();
|
||||
|
||||
if (!ALLOWED_TAGS.has(tag)) return '';
|
||||
|
||||
const allowedForTag = ALLOWED_ATTRS[tag] || new Set();
|
||||
const allowedGlobal = ALLOWED_ATTRS['*'];
|
||||
const combined = new Set([...allowedForTag, ...allowedGlobal]);
|
||||
|
||||
const cleaned = fullMatch.replace(ATTR_RE, (_, attrName, dqVal, sqVal) => {
|
||||
const attr = attrName.toLowerCase();
|
||||
const val = dqVal ?? sqVal ?? '';
|
||||
|
||||
if (!combined.has(attr)) return '';
|
||||
|
||||
if (URL_ATTRS.has(attr) && !SAFE_URL_RE.test(val)) return '';
|
||||
|
||||
return ` ${attr}="${val}"`;
|
||||
});
|
||||
|
||||
return cleaned;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,22 +1,98 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
|
||||
/**
|
||||
* 持久化存储工具 — 小程序版本
|
||||
*
|
||||
* 敏感数据依赖 HTTPS 传输 + 后端 AES-256-GCM 加密保护。
|
||||
* 导出函数名保留 secure* 前缀以保持调用点兼容,实际为明文存储。
|
||||
*/
|
||||
const ENCRYPTION_KEY = process.env.TARO_APP_ENCRYPTION_KEY || 'hms-default-key';
|
||||
|
||||
function xorEncrypt(data: string, key: string): string {
|
||||
let result = '';
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
result += String.fromCharCode(data.charCodeAt(i) ^ key.charCodeAt(i % key.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function toBase64(str: string): string {
|
||||
try {
|
||||
const buffer = new Uint8Array(str.length);
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
buffer[i] = str.charCodeAt(i);
|
||||
}
|
||||
return Taro.arrayBufferToBase64(buffer.buffer);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function fromBase64(b64: string): string {
|
||||
try {
|
||||
const buffer = Taro.base64ToArrayBuffer(b64);
|
||||
const arr = new Uint8Array(buffer);
|
||||
let result = '';
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
result += String.fromCharCode(arr[i]);
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
const STORAGE_PREFIX = '_es_';
|
||||
|
||||
export function secureSet(key: string, value: string): void {
|
||||
Taro.setStorageSync(key, value);
|
||||
if (!value) {
|
||||
Taro.removeStorageSync(STORAGE_PREFIX + key);
|
||||
return;
|
||||
}
|
||||
const encrypted = xorEncrypt(value, ENCRYPTION_KEY);
|
||||
const encoded = toBase64(encrypted);
|
||||
if (encoded) {
|
||||
Taro.setStorageSync(STORAGE_PREFIX + key, encoded);
|
||||
} else {
|
||||
Taro.setStorageSync(STORAGE_PREFIX + key, value);
|
||||
}
|
||||
}
|
||||
|
||||
export function secureGet(key: string): string {
|
||||
const raw = Taro.getStorageSync(key);
|
||||
const prefixedKey = STORAGE_PREFIX + key;
|
||||
const raw = Taro.getStorageSync(prefixedKey);
|
||||
if (!raw || typeof raw !== 'string') return '';
|
||||
|
||||
if (raw.startsWith('{') || raw.startsWith('eyJ')) {
|
||||
try {
|
||||
const decoded = fromBase64(raw);
|
||||
if (decoded) {
|
||||
return xorEncrypt(decoded, ENCRYPTION_KEY);
|
||||
}
|
||||
} catch {
|
||||
// fallthrough
|
||||
}
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function secureRemove(key: string): void {
|
||||
Taro.removeStorageSync(key);
|
||||
Taro.removeStorageSync(STORAGE_PREFIX + key);
|
||||
}
|
||||
|
||||
const MIGRATION_KEYS = [
|
||||
'access_token', 'refresh_token', 'token_expires_at',
|
||||
'user_data', 'user_roles', 'tenant_id', 'wechat_openid',
|
||||
];
|
||||
|
||||
export function migrateLegacyStorage(): void {
|
||||
try {
|
||||
for (const key of MIGRATION_KEYS) {
|
||||
const prefixed = STORAGE_PREFIX + key;
|
||||
const already = Taro.getStorageSync(prefixed);
|
||||
if (already) continue;
|
||||
|
||||
const legacy = Taro.getStorageSync(key);
|
||||
if (!legacy || typeof legacy !== 'string') continue;
|
||||
|
||||
secureSet(key, legacy);
|
||||
Taro.removeStorageSync(key);
|
||||
}
|
||||
} catch {
|
||||
// migration best-effort
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user