fix: 安全加固 + 首页推荐优先加载解耦

- install-manager: queue-install JSON.parse 加 try-catch 防异常 payload
- App.vue: openExternalUrl 协议白名单(http/https) 防恶意 scheme
- AppDetailModal: sanitizeMoreContent 改用 textContent 防 v-html XSS
- storeConfig: loadPriorityConfig 校验顶层结构,畸形数据回退 APM
- App.vue onMounted: 首页推荐(loadHome+preloadHomeListApps) 与全量
  应用加载(loadApps) 解耦,进入软件即优先显示首页推荐
This commit is contained in:
xiyidaiwa
2026-08-10 23:40:21 +08:00
parent 5bd0a367c4
commit 102ac72d89
4 changed files with 47 additions and 24 deletions
+10 -4
View File
@@ -285,10 +285,16 @@ const parseUpgradableList = (output: string) => {
// Listen for download requests from renderer process // Listen for download requests from renderer process
ipcMain.on("queue-install", async (event, download_json) => { ipcMain.on("queue-install", async (event, download_json) => {
const download = let download: unknown;
typeof download_json === "string" try {
? JSON.parse(download_json) download =
: download_json; typeof download_json === "string"
? JSON.parse(download_json)
: download_json;
} catch (err) {
logger.error({ err }, "queue-install: invalid JSON payload, ignoring task");
return;
}
const { id, pkgname, metalinkUrl, filename, origin, upgradeOnly } = const { id, pkgname, metalinkUrl, filename, origin, upgradeOnly } =
download || {}; download || {};
+23 -13
View File
@@ -1134,7 +1134,10 @@ const openDetail = async (app: App | OpenDetailInput) => {
// 非强制分支:清除可能由「已安装页」入口遗留的 forceViewingOrigin 粘性标志, // 非强制分支:清除可能由「已安装页」入口遗留的 forceViewingOrigin 粘性标志,
// 避免同一应用从其他页面再次打开时被锁死在旧来源(表现为设置切换不生效)。 // 避免同一应用从其他页面再次打开时被锁死在旧来源(表现为设置切换不生效)。
finalApp.forceViewingOrigin = false; finalApp.forceViewingOrigin = false;
if (forceOrigin && (forceOrigin === "spark" ? finalApp.sparkApp : finalApp.apmApp)) { if (
forceOrigin &&
(forceOrigin === "spark" ? finalApp.sparkApp : finalApp.apmApp)
) {
// 已安装页 + 唯一安装来源 → 强制展示该安装类型,优先级高于用户标签策略 // 已安装页 + 唯一安装来源 → 强制展示该安装类型,优先级高于用户标签策略
finalApp.viewingOrigin = forceOrigin; finalApp.viewingOrigin = forceOrigin;
finalApp.forceViewingOrigin = true; finalApp.forceViewingOrigin = true;
@@ -1544,9 +1547,8 @@ const loadHomeListApps = async (entryId: string) => {
rawApps.map((a) => { rawApps.map((a) => {
// 首页推荐列表的 jsonUrl 形如 /home/lists/xxx.json;服务端原始数据不含 category 字段, // 首页推荐列表的 jsonUrl 形如 /home/lists/xxx.json;服务端原始数据不含 category 字段,
// 这里提取 URL 首段目录作为分类(无真实分类时回退 "unknown"),避免污染后续优先级匹配。 // 这里提取 URL 首段目录作为分类(无真实分类时回退 "unknown"),避免污染后续优先级匹配。
const urlCategory = (urls[mode] || "") const urlCategory =
.split("/") (urls[mode] || "").split("/").filter(Boolean)[0] || "unknown";
.filter(Boolean)[0] || "unknown";
const category = a.Category || a.category || urlCategory; const category = a.Category || a.category || urlCategory;
let img_urls: string[] = []; let img_urls: string[] = [];
@@ -2176,7 +2178,14 @@ const closeSettingsModal = () => {
}; };
const openExternalUrl = (url: string) => { const openExternalUrl = (url: string) => {
window.open(url, "_blank", "noopener,noreferrer"); try {
const parsed = new URL(url);
// 仅允许 http/https 协议,防止 javascript:/data: 等造成 XSS
if (!["http:", "https:"].includes(parsed.protocol)) return;
window.open(url, "_blank", "noopener,noreferrer");
} catch {
// 非法 URL 直接忽略,不打开
}
}; };
const openReviewUserProfile = (review: AppReview): void => { const openReviewUserProfile = (review: AppReview): void => {
@@ -3426,14 +3435,15 @@ onMounted(async () => {
loading.value = true; loading.value = true;
homeLoading.value = true; homeLoading.value = true;
// 区域1(links) 与 区域2 板块(homeList) 并行;全量应用也并行加载 // 首页推荐(区域1 links + 区域2 homeList 板块)轻量且独立,
await Promise.all([ // 不依赖全量应用,进入软件即优先并行加载并显示,不再与 loadApps 耦合。
loadHome(), await Promise.all([loadHome(), preloadHomeListApps()]);
preloadHomeListApps(),
loadApps(() => { // 全量应用后台加载(不阻塞首页推荐):首个分类成功即关闭首屏遮罩,
loading.value = false; // 首屏:已有部分应用即可显示,排行稍后刷新 // 增量渲染,排行稍后由 loadRanking 刷新
}), void loadApps(() => {
]); loading.value = false; // 首屏:已有部分应用即可显示
});
// 全量应用加载完成后再刷新排行榜,确保 spark/apm 应用均已就绪 // 全量应用加载完成后再刷新排行榜,确保 spark/apm 应用均已就绪
loadRanking(); loadRanking();
+7 -6
View File
@@ -587,15 +587,16 @@ import type { App, AppReview, ReviewTags } from "../global/typedefinition";
/** /**
* 净化后端返回的应用描述(v-html 前严格去除所有标签)。 * 净化后端返回的应用描述(v-html 前严格去除所有标签)。
* 程序先将用户内容中的所有 HTML 标签剥离,再将 \n 转为 <br>。 * 使用 textContent 做 HTML 实体转义,彻底杜绝 <script>/onerror/嵌套标签/编码绕过等 XSS;
* <br> 是纯程序生成,不含任何用户输入,可安全放入 v-html。 * 再将 \n 转为 <br>。<br> 是纯程序生成,不含任何用户输入,可安全放入 v-html。
*/ */
const sanitizeMoreContent = (raw: string): string => { const sanitizeMoreContent = (raw: string): string => {
if (!raw) return ""; if (!raw) return "";
// 去除所有 HTML 标签,避免 XSS(包括 <script>/onerror/等 // 用 textContent 转义所有 HTML 特殊字符(比正则替换 /<[^>]*>/g 更安全,防嵌套/编码绕过
const stripped = raw.replace(/<[^>]*>/g, ""); const stripped = document.createElement("div");
// 将 \n 转为安全的 <br> stripped.textContent = raw;
return stripped.replace(/\n/g, "<br>"); // 将 \n 转为安全的 <br>(此时 textContent 已是转义后的纯文本字符串)
return (stripped.textContent ?? "").replace(/\n/g, "<br>");
}; };
const attrs = useAttrs(); const attrs = useAttrs();
+7 -1
View File
@@ -83,6 +83,10 @@ export async function loadPriorityConfig(arch: string): Promise<void> {
); );
const response = await priorityConfigAxios.get(configPath); const response = await priorityConfigAxios.get(configPath);
const config = response.data; const config = response.data;
// 顶层结构校验:畸形数据(非对象/为数组/null)直接走失败回退,避免访问属性抛错
if (!config || typeof config !== "object" || Array.isArray(config)) {
throw new Error("Invalid priority-config format: expected object");
}
// 支持新旧两种配置格式 // 支持新旧两种配置格式
if (config.sparkPriority || config.apmPriority) { if (config.sparkPriority || config.apmPriority) {
// 新格式:双向配置 // 新格式:双向配置
@@ -204,7 +208,9 @@ export function getHybridDefaultOrigin(app: App): "apm" | "spark" {
// 构造 category 回退顶层的候选项,避免子版 category 为空时分类规则漏匹配 // 构造 category 回退顶层的候选项,避免子版 category 为空时分类规则漏匹配
const toCandidate = (sub: App): App => const toCandidate = (sub: App): App =>
sub.category ? sub : { ...sub, category: fallbackCategory }; sub.category ? sub : { ...sub, category: fallbackCategory };
const sparkHit = app.sparkApp ? matchPriority(toCandidate(app.sparkApp)) : null; const sparkHit = app.sparkApp
? matchPriority(toCandidate(app.sparkApp))
: null;
const apmHit = app.apmApp ? matchPriority(toCandidate(app.apmApp)) : null; const apmHit = app.apmApp ? matchPriority(toCandidate(app.apmApp)) : null;
// apmPriority 是「例外优先于 sparkPriority」:当两子版方向相反命中 // apmPriority 是「例外优先于 sparkPriority」:当两子版方向相反命中