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:
iven
2026-05-21 13:35:46 +08:00
parent e769a5785a
commit 652cccf66c
20 changed files with 441 additions and 99 deletions

View File

@@ -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;
});
}

View File

@@ -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
}
}