mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-09-20 13:40:11 +08:00
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:
@@ -285,10 +285,16 @@ const parseUpgradableList = (output: string) => {
|
||||
|
||||
// Listen for download requests from renderer process
|
||||
ipcMain.on("queue-install", async (event, download_json) => {
|
||||
const download =
|
||||
typeof download_json === "string"
|
||||
? JSON.parse(download_json)
|
||||
: download_json;
|
||||
let download: unknown;
|
||||
try {
|
||||
download =
|
||||
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 } =
|
||||
download || {};
|
||||
|
||||
|
||||
+23
-13
@@ -1134,7 +1134,10 @@ const openDetail = async (app: App | OpenDetailInput) => {
|
||||
// 非强制分支:清除可能由「已安装页」入口遗留的 forceViewingOrigin 粘性标志,
|
||||
// 避免同一应用从其他页面再次打开时被锁死在旧来源(表现为设置切换不生效)。
|
||||
finalApp.forceViewingOrigin = false;
|
||||
if (forceOrigin && (forceOrigin === "spark" ? finalApp.sparkApp : finalApp.apmApp)) {
|
||||
if (
|
||||
forceOrigin &&
|
||||
(forceOrigin === "spark" ? finalApp.sparkApp : finalApp.apmApp)
|
||||
) {
|
||||
// 已安装页 + 唯一安装来源 → 强制展示该安装类型,优先级高于用户标签策略
|
||||
finalApp.viewingOrigin = forceOrigin;
|
||||
finalApp.forceViewingOrigin = true;
|
||||
@@ -1544,9 +1547,8 @@ const loadHomeListApps = async (entryId: string) => {
|
||||
rawApps.map((a) => {
|
||||
// 首页推荐列表的 jsonUrl 形如 /home/lists/xxx.json;服务端原始数据不含 category 字段,
|
||||
// 这里提取 URL 首段目录作为分类(无真实分类时回退 "unknown"),避免污染后续优先级匹配。
|
||||
const urlCategory = (urls[mode] || "")
|
||||
.split("/")
|
||||
.filter(Boolean)[0] || "unknown";
|
||||
const urlCategory =
|
||||
(urls[mode] || "").split("/").filter(Boolean)[0] || "unknown";
|
||||
const category = a.Category || a.category || urlCategory;
|
||||
|
||||
let img_urls: string[] = [];
|
||||
@@ -2176,7 +2178,14 @@ const closeSettingsModal = () => {
|
||||
};
|
||||
|
||||
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 => {
|
||||
@@ -3426,14 +3435,15 @@ onMounted(async () => {
|
||||
loading.value = true;
|
||||
homeLoading.value = true;
|
||||
|
||||
// 区域1(links) 与 区域2 板块(homeList) 并行;全量应用也并行加载
|
||||
await Promise.all([
|
||||
loadHome(),
|
||||
preloadHomeListApps(),
|
||||
loadApps(() => {
|
||||
loading.value = false; // 首屏:已有部分应用即可显示,排行稍后刷新
|
||||
}),
|
||||
]);
|
||||
// 首页推荐(区域1 links + 区域2 homeList 板块)轻量且独立,
|
||||
// 不依赖全量应用,进入软件即优先并行加载并显示,不再与 loadApps 耦合。
|
||||
await Promise.all([loadHome(), preloadHomeListApps()]);
|
||||
|
||||
// 全量应用后台加载(不阻塞首页推荐):首个分类成功即关闭首屏遮罩,
|
||||
// 增量渲染,排行稍后由 loadRanking 刷新。
|
||||
void loadApps(() => {
|
||||
loading.value = false; // 首屏:已有部分应用即可显示
|
||||
});
|
||||
|
||||
// 全量应用加载完成后再刷新排行榜,确保 spark/apm 应用均已就绪
|
||||
loadRanking();
|
||||
|
||||
@@ -587,15 +587,16 @@ import type { App, AppReview, ReviewTags } from "../global/typedefinition";
|
||||
|
||||
/**
|
||||
* 净化后端返回的应用描述(v-html 前严格去除所有标签)。
|
||||
* 程序先将用户内容中的所有 HTML 标签剥离,再将 \n 转为 <br>。
|
||||
* <br> 是纯程序生成,不含任何用户输入,可安全放入 v-html。
|
||||
* 使用 textContent 做 HTML 实体转义,彻底杜绝 <script>/onerror/嵌套标签/编码绕过等 XSS;
|
||||
* 再将 \n 转为 <br>。<br> 是纯程序生成,不含任何用户输入,可安全放入 v-html。
|
||||
*/
|
||||
const sanitizeMoreContent = (raw: string): string => {
|
||||
if (!raw) return "";
|
||||
// 去除所有 HTML 标签,避免 XSS(包括 <script>/onerror/等)
|
||||
const stripped = raw.replace(/<[^>]*>/g, "");
|
||||
// 将 \n 转为安全的 <br>
|
||||
return stripped.replace(/\n/g, "<br>");
|
||||
// 用 textContent 转义所有 HTML 特殊字符(比正则替换 /<[^>]*>/g 更安全,防嵌套/编码绕过)
|
||||
const stripped = document.createElement("div");
|
||||
stripped.textContent = raw;
|
||||
// 将 \n 转为安全的 <br>(此时 textContent 已是转义后的纯文本字符串)
|
||||
return (stripped.textContent ?? "").replace(/\n/g, "<br>");
|
||||
};
|
||||
|
||||
const attrs = useAttrs();
|
||||
|
||||
@@ -83,6 +83,10 @@ export async function loadPriorityConfig(arch: string): Promise<void> {
|
||||
);
|
||||
const response = await priorityConfigAxios.get(configPath);
|
||||
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) {
|
||||
// 新格式:双向配置
|
||||
@@ -204,7 +208,9 @@ export function getHybridDefaultOrigin(app: App): "apm" | "spark" {
|
||||
// 构造 category 回退顶层的候选项,避免子版 category 为空时分类规则漏匹配
|
||||
const toCandidate = (sub: App): App =>
|
||||
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;
|
||||
|
||||
// apmPriority 是「例外优先于 sparkPriority」:当两子版方向相反命中
|
||||
|
||||
Reference in New Issue
Block a user