mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-09-20 21:50:11 +08:00
fix: 安全与健壮性排查修复(IPC 类型校验/AbortSignal/空名去重/窗口 await)
阻断项: - install-manager: uninstall-installed / remove-installed / check-installed 由 any 改为 unknown + parseAppPayload 严格解析(PKGNAME_PATTERN 校验 pkgname,origin 仅限 spark|apm),消除命令注入风险;parseAppPayload/PKGNAME_PATTERN 提升至模块顶部复用并避免 TDZ - App.vue openDetail: 参数由 Record<string,unknown> 收紧为 App | OpenDetailInput,新增 pkgname 运行时校验,避免访问 undefined 属性 改进项: - App.vue homeLinks 去重:跳过空名称避免污染 seenNames,并对 links 数组与 HomeLink 必需字段做校验 - App.vue fetchWithRetry: 新增 AbortSignal 参数,递归链路传递,组件卸载时 rootAbortController.abort() 取消未完成任务 - index.ts showAndFocusMainWindow: 改为 async 并 await createWindow,调用点用 void 修饰避免 floating promise vue-tsc --noEmit 通过。
This commit is contained in:
@@ -12,6 +12,26 @@ import { findExecutable, SUPER_USER_COMMAND_CANDIDATES } from "./superuser";
|
|||||||
|
|
||||||
const logger = pino({ name: "install-manager" });
|
const logger = pino({ name: "install-manager" });
|
||||||
|
|
||||||
|
// 包名白名单:仅允许合法包名字符,杜绝命令注入(spawn 用 shell:false 仍须校验)。
|
||||||
|
const PKGNAME_PATTERN = /^[a-zA-Z0-9._+-]+$/;
|
||||||
|
|
||||||
|
// 解析并校验应用类 IPC 的 payload(可能是旧版字符串或对象)。
|
||||||
|
// 返回规范化后的 { pkgname, origin },pkgname 非法时返回 null。
|
||||||
|
const parseAppPayload = (
|
||||||
|
payload: unknown,
|
||||||
|
): { pkgname: string; origin: "spark" | "apm" } | null => {
|
||||||
|
if (typeof payload === "string") {
|
||||||
|
if (!PKGNAME_PATTERN.test(payload)) return null;
|
||||||
|
return { pkgname: payload, origin: "spark" };
|
||||||
|
}
|
||||||
|
if (typeof payload !== "object" || payload === null) return null;
|
||||||
|
const p = payload as Record<string, unknown>;
|
||||||
|
const pkgname = typeof p.pkgname === "string" ? p.pkgname : "";
|
||||||
|
if (!PKGNAME_PATTERN.test(pkgname)) return null;
|
||||||
|
const origin: "spark" | "apm" = p.origin === "apm" ? "apm" : "spark";
|
||||||
|
return { pkgname, origin };
|
||||||
|
};
|
||||||
|
|
||||||
const getStoreFilterFromArgv = (): "spark" | "apm" | "both" => {
|
const getStoreFilterFromArgv = (): "spark" | "apm" | "both" => {
|
||||||
const argv = process.argv;
|
const argv = process.argv;
|
||||||
const noApm = argv.includes("--no-apm");
|
const noApm = argv.includes("--no-apm");
|
||||||
@@ -815,15 +835,13 @@ async function runInstallPhase(task: InstallTask) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
ipcMain.handle("check-installed", async (_event, payload: unknown) => {
|
||||||
ipcMain.handle("check-installed", async (_event, payload: any) => {
|
const parsed = parseAppPayload(payload);
|
||||||
const pkgname = typeof payload === "string" ? payload : payload.pkgname;
|
if (!parsed) {
|
||||||
const origin = typeof payload === "string" ? "spark" : payload.origin;
|
logger.warn("check-installed invalid payload");
|
||||||
|
|
||||||
if (!pkgname) {
|
|
||||||
logger.warn("check-installed missing pkgname");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const { pkgname, origin } = parsed;
|
||||||
|
|
||||||
logger.info(`检查应用是否已安装: ${pkgname} (来源: ${origin})`);
|
logger.info(`检查应用是否已安装: ${pkgname} (来源: ${origin})`);
|
||||||
|
|
||||||
@@ -890,13 +908,12 @@ ipcMain.handle("check-installed", async (_event, payload: any) => {
|
|||||||
|
|
||||||
ipcMain.on("remove-installed", async (_event, payload) => {
|
ipcMain.on("remove-installed", async (_event, payload) => {
|
||||||
const webContents = _event.sender;
|
const webContents = _event.sender;
|
||||||
const pkgname = typeof payload === "string" ? payload : payload.pkgname;
|
const parsed = parseAppPayload(payload);
|
||||||
const origin = typeof payload === "string" ? "spark" : payload.origin;
|
if (!parsed) {
|
||||||
|
logger.warn("remove-installed invalid payload");
|
||||||
if (!pkgname) {
|
|
||||||
logger.warn("remove-installed missing pkgname");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const { pkgname, origin } = parsed;
|
||||||
logger.info(`卸载已安装应用: ${pkgname} (来源: ${origin})`);
|
logger.info(`卸载已安装应用: ${pkgname} (来源: ${origin})`);
|
||||||
|
|
||||||
let execCommand = "";
|
let execCommand = "";
|
||||||
@@ -1243,14 +1260,18 @@ ipcMain.handle("show-apm-install-dialog", async (event) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
ipcMain.handle("uninstall-installed", async (_event, payload: any) => {
|
ipcMain.handle(
|
||||||
const pkgname = typeof payload === "string" ? payload : payload.pkgname;
|
"uninstall-installed",
|
||||||
const origin = typeof payload === "string" ? "spark" : payload.origin;
|
async (
|
||||||
|
_event,
|
||||||
if (!pkgname) {
|
payload: unknown,
|
||||||
logger.warn("uninstall-installed missing pkgname");
|
): Promise<{ success: boolean; message?: string }> => {
|
||||||
return { success: false, message: "missing pkgname" };
|
const parsed = parseAppPayload(payload);
|
||||||
|
if (!parsed) {
|
||||||
|
logger.warn("uninstall-installed invalid payload");
|
||||||
|
return { success: false, message: "invalid payload" };
|
||||||
}
|
}
|
||||||
|
const { pkgname, origin } = parsed;
|
||||||
|
|
||||||
const superUserCmd = await checkSuperUserCommand();
|
const superUserCmd = await checkSuperUserCommand();
|
||||||
const execCommand = superUserCmd || SHELL_CALLER_PATH;
|
const execCommand = superUserCmd || SHELL_CALLER_PATH;
|
||||||
@@ -1287,9 +1308,6 @@ interface LaunchAppPayload {
|
|||||||
origin?: "spark" | "apm";
|
origin?: "spark" | "apm";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 合法包名字符(Debian 包名规范 + Spark 应用包名常见字符),
|
|
||||||
// 用于拦截包含特殊字符的非法输入,避免命令注入。
|
|
||||||
const PKGNAME_PATTERN = /^[a-zA-Z0-9._+-]+$/;
|
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
"launch-app",
|
"launch-app",
|
||||||
|
|||||||
@@ -272,9 +272,10 @@ const requestApplicationExit = (): void => {
|
|||||||
app.quit();
|
app.quit();
|
||||||
};
|
};
|
||||||
|
|
||||||
const showAndFocusMainWindow = (): void => {
|
const showAndFocusMainWindow = async (): Promise<void> => {
|
||||||
if (!win || win.isDestroyed()) {
|
if (!win || win.isDestroyed()) {
|
||||||
createWindow();
|
// 等待窗口创建完成,创建失败时调用方可通过异常感知
|
||||||
|
await createWindow();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -643,11 +644,11 @@ app.on("window-all-closed", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.on("second-instance", () => {
|
app.on("second-instance", () => {
|
||||||
showAndFocusMainWindow();
|
void showAndFocusMainWindow();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on("activate", () => {
|
app.on("activate", () => {
|
||||||
showAndFocusMainWindow();
|
void showAndFocusMainWindow();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on("will-quit", () => {
|
app.on("will-quit", () => {
|
||||||
@@ -700,7 +701,7 @@ app.whenReady().then(() => {
|
|||||||
{
|
{
|
||||||
label: "显示主界面",
|
label: "显示主界面",
|
||||||
click: () => {
|
click: () => {
|
||||||
showAndFocusMainWindow();
|
void showAndFocusMainWindow();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -719,7 +720,7 @@ app.whenReady().then(() => {
|
|||||||
win.hide();
|
win.hide();
|
||||||
win.setSkipTaskbar(true);
|
win.setSkipTaskbar(true);
|
||||||
} else {
|
} else {
|
||||||
showAndFocusMainWindow();
|
void showAndFocusMainWindow();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+54
-24
@@ -431,13 +431,16 @@ const axiosInstance = axios.create({
|
|||||||
|
|
||||||
const fetchWithRetry = async <T,>(
|
const fetchWithRetry = async <T,>(
|
||||||
url: string,
|
url: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
retries = 3,
|
retries = 3,
|
||||||
delay = 1000,
|
delay = 1000,
|
||||||
): Promise<T> => {
|
): Promise<T> => {
|
||||||
try {
|
try {
|
||||||
const response = await axiosInstance.get<T>(url);
|
const response = await axiosInstance.get<T>(url, { signal });
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// 请求被取消(AbortSignal)时直接抛出,不再重试
|
||||||
|
if (signal?.aborted) throw error;
|
||||||
const axiosError = error as AxiosError;
|
const axiosError = error as AxiosError;
|
||||||
const status = axiosError.response?.status;
|
const status = axiosError.response?.status;
|
||||||
// 仅对网络错误(无响应)、服务端 5xx 错误或超时进行重试;
|
// 仅对网络错误(无响应)、服务端 5xx 错误或超时进行重试;
|
||||||
@@ -448,13 +451,18 @@ const fetchWithRetry = async <T,>(
|
|||||||
axiosError.code === "ECONNABORTED" || axiosError.code === "ETIMEDOUT";
|
axiosError.code === "ECONNABORTED" || axiosError.code === "ETIMEDOUT";
|
||||||
|
|
||||||
if (retries > 0 && (isNetworkError || isServerError || isTimeout)) {
|
if (retries > 0 && (isNetworkError || isServerError || isTimeout)) {
|
||||||
|
// 若已取消则提前退出,避免对已卸载组件发起新请求
|
||||||
|
if (signal?.aborted) throw error;
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
return fetchWithRetry(url, retries - 1, delay * 2);
|
return fetchWithRetry(url, signal, retries - 1, delay * 2);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 根级请求取消控制器:组件卸载时 abort,避免对已卸载组件发起/重试请求
|
||||||
|
const rootAbortController = new AbortController();
|
||||||
|
|
||||||
// 渲染进程从 IPC 拿到的 result.apps 实际类型为 any(ipcRenderer.invoke 返回 Promise<any>),
|
// 渲染进程从 IPC 拿到的 result.apps 实际类型为 any(ipcRenderer.invoke 返回 Promise<any>),
|
||||||
// 直接断言成 InstalledAppInfo[] 会绕过运行时类型检查。后端字段缺失时会引发运行时错误。
|
// 直接断言成 InstalledAppInfo[] 会绕过运行时类型检查。后端字段缺失时会引发运行时错误。
|
||||||
// 此守卫仅校验本项目实际使用的关键字段,后端字段缺失时跳过即可,避免整批失败。
|
// 此守卫仅校验本项目实际使用的关键字段,后端字段缺失时跳过即可,避免整批失败。
|
||||||
@@ -926,28 +934,36 @@ const openDetailFromInstalled = (app: App) => {
|
|||||||
openDetail({ ...app, _fromInstalled: true });
|
openDetail({ ...app, _fromInstalled: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
const openDetail = async (app: App | Record<string, unknown>) => {
|
// openDetail 输入类型:完整 App 或仅含必要字段的轻量对象(含内部来源标记)
|
||||||
|
interface OpenDetailInput extends Partial<App> {
|
||||||
|
pkgname?: string;
|
||||||
|
category?: string;
|
||||||
|
_fromHomeView?: boolean;
|
||||||
|
_fromInstalled?: boolean;
|
||||||
|
_fromDeepLink?: boolean;
|
||||||
|
origin?: "spark" | "apm";
|
||||||
|
}
|
||||||
|
|
||||||
|
const openDetail = async (app: App | OpenDetailInput) => {
|
||||||
// 提取 pkgname 和 category(必须存在)
|
// 提取 pkgname 和 category(必须存在)
|
||||||
const pkgname = (app as Record<string, unknown>).pkgname as string;
|
const pkgname = app?.pkgname;
|
||||||
const category =
|
|
||||||
((app as Record<string, unknown>).category as string) || "unknown";
|
|
||||||
// 检查是否来自 HomeView 或 DeepLink(需要重新获取完整信息)
|
|
||||||
const fromHomeView = (app as Record<string, unknown>)._fromHomeView === true;
|
|
||||||
const fromDeepLink = (app as Record<string, unknown>)._fromDeepLink === true;
|
|
||||||
// 已安装应用页:始终按"所有应用页"的方式双来源拉取并合并展示(不移除另一类型)
|
|
||||||
const fromInstalled = (app as Record<string, unknown>)._fromInstalled === true;
|
|
||||||
const needFetchFromStore = fromHomeView || fromDeepLink || fromInstalled;
|
|
||||||
if (!pkgname) {
|
if (!pkgname) {
|
||||||
console.warn("openDetail: 缺少 pkgname", app);
|
console.warn("openDetail called without pkgname");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const category = app.category || "unknown";
|
||||||
|
// 检查是否来自 HomeView 或 DeepLink(需要重新获取完整信息)
|
||||||
|
// 内部来源标记仅存在于轻量输入对象上,按 OpenDetailInput 读取以兼容联合类型
|
||||||
|
const marker = app as OpenDetailInput;
|
||||||
|
const fromHomeView = marker._fromHomeView === true;
|
||||||
|
const fromDeepLink = marker._fromDeepLink === true;
|
||||||
|
// 已安装应用页:始终按"所有应用页"的方式双来源拉取并合并展示(不移除另一类型)
|
||||||
|
const fromInstalled = marker._fromInstalled === true;
|
||||||
|
const needFetchFromStore = fromHomeView || fromDeepLink || fromInstalled;
|
||||||
|
|
||||||
// 首先尝试从当前已经处理好(合并/筛选)的 filteredApps 中查找
|
// 首先尝试从当前已经处理好(合并/筛选)的 filteredApps 中查找
|
||||||
// 优先匹配点击来源 origin(例如排行点击 APM 应用时不应误匹配到 Spark 版)
|
// 优先匹配点击来源 origin(例如排行点击 APM 应用时不应误匹配到 Spark 版)
|
||||||
const clickedOrigin = (app as Record<string, unknown>).origin as
|
const clickedOrigin = app.origin as "spark" | "apm" | undefined;
|
||||||
| "spark"
|
|
||||||
| "apm"
|
|
||||||
| undefined;
|
|
||||||
let fullApp = filteredApps.value.find(
|
let fullApp = filteredApps.value.find(
|
||||||
(a) =>
|
(a) =>
|
||||||
a.pkgname === pkgname && (!clickedOrigin || a.origin === clickedOrigin),
|
a.pkgname === pkgname && (!clickedOrigin || a.origin === clickedOrigin),
|
||||||
@@ -1351,12 +1367,25 @@ const loadHome = async () => {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`${base}/homelinks.json`);
|
const res = await fetch(`${base}/homelinks.json`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const links = await res.json();
|
const raw = await res.json();
|
||||||
|
// 校验 links 为数组,且每项均为对象(避免后端返回异常结构导致运行时错误)
|
||||||
|
const links = Array.isArray(raw) ? (raw.filter((x) => x && typeof x === "object") as Record<string, unknown>[]) : [];
|
||||||
for (const l of links) {
|
for (const l of links) {
|
||||||
const name = l.Name || l.name || "";
|
const name = (l.Name as string) || (l.name as string) || "";
|
||||||
|
if (!name) continue; // 跳过空名称,避免空字符串污染 seenNames 与去重逻辑
|
||||||
if (seenNames.has(name)) continue; // 已由更高优先级来源(spark)占据
|
if (seenNames.has(name)) continue; // 已由更高优先级来源(spark)占据
|
||||||
|
// 校验 HomeLink 必需字段,缺失则跳过,避免推入不完整对象导致运行时错误
|
||||||
|
const url = (l.Url as string) || (l.url as string) || "";
|
||||||
|
const icon = (l.Icon as string) || (l.icon as string) || "";
|
||||||
|
if (!url || !icon) continue;
|
||||||
seenNames.add(name);
|
seenNames.add(name);
|
||||||
homeLinks.value.push({ ...l, origin: mode });
|
homeLinks.value.push({
|
||||||
|
...l,
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
icon,
|
||||||
|
origin: mode,
|
||||||
|
} as HomeLink);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1507,7 +1536,7 @@ const loadHomeListApps = async (entryId: string) => {
|
|||||||
try {
|
try {
|
||||||
const path = `/${finalArch}${jsonUrl}`;
|
const path = `/${finalArch}${jsonUrl}`;
|
||||||
const rawApps =
|
const rawApps =
|
||||||
(await fetchWithRetry<Record<string, string>[]>(path)) || [];
|
(await fetchWithRetry<Record<string, string>[]>(path, rootAbortController.signal)) || [];
|
||||||
const apps = parseAppList(rawApps, mode);
|
const apps = parseAppList(rawApps, mode);
|
||||||
for (const app of apps) {
|
for (const app of apps) {
|
||||||
if (!app.pkgname || seenPkgnames.has(app.pkgname)) continue;
|
if (!app.pkgname || seenPkgnames.has(app.pkgname)) continue;
|
||||||
@@ -3027,7 +3056,7 @@ const loadTabApps = async (entryId: string) => {
|
|||||||
const path = `/${finalArch}/${folderName}/${subCat}/applist.json`;
|
const path = `/${finalArch}/${folderName}/${subCat}/applist.json`;
|
||||||
logger.info(`加载入口子分类: ${entryId}/${subCat} (来源: ${mode})`);
|
logger.info(`加载入口子分类: ${entryId}/${subCat} (来源: ${mode})`);
|
||||||
tasks.push(
|
tasks.push(
|
||||||
fetchWithRetry<AppJson[]>(path)
|
fetchWithRetry<AppJson[]>(path, rootAbortController.signal)
|
||||||
.then((categoryApps) =>
|
.then((categoryApps) =>
|
||||||
(categoryApps || []).map((aj) =>
|
(categoryApps || []).map((aj) =>
|
||||||
normalizeAppJson(aj, subCat, mode),
|
normalizeAppJson(aj, subCat, mode),
|
||||||
@@ -3045,7 +3074,7 @@ const loadTabApps = async (entryId: string) => {
|
|||||||
const path = `/${finalArch}/${folderName}/applist.json`;
|
const path = `/${finalArch}/${folderName}/applist.json`;
|
||||||
logger.info(`加载入口目录: ${entryId} (来源: ${mode})`);
|
logger.info(`加载入口目录: ${entryId} (来源: ${mode})`);
|
||||||
tasks.push(
|
tasks.push(
|
||||||
fetchWithRetry<AppJson[]>(path)
|
fetchWithRetry<AppJson[]>(path, rootAbortController.signal)
|
||||||
.then((categoryApps) =>
|
.then((categoryApps) =>
|
||||||
(categoryApps || []).map((aj) =>
|
(categoryApps || []).map((aj) =>
|
||||||
normalizeAppJson(aj, folderName, mode),
|
normalizeAppJson(aj, folderName, mode),
|
||||||
@@ -3097,7 +3126,7 @@ const loadApps = async (onFirstBatch?: () => void) => {
|
|||||||
const path = `/${finalArch}/${category}/applist.json`;
|
const path = `/${finalArch}/${category}/applist.json`;
|
||||||
|
|
||||||
logger.info(`加载分类: ${category} (来源: ${mode})`);
|
logger.info(`加载分类: ${category} (来源: ${mode})`);
|
||||||
const categoryApps = await fetchWithRetry<AppJson[]>(path);
|
const categoryApps = await fetchWithRetry<AppJson[]>(path, rootAbortController.signal);
|
||||||
|
|
||||||
const normalizedApps = (categoryApps || []).map((appJson) =>
|
const normalizedApps = (categoryApps || []).map((appJson) =>
|
||||||
normalizeAppJson(appJson, category, mode as "spark" | "apm"),
|
normalizeAppJson(appJson, category, mode as "spark" | "apm"),
|
||||||
@@ -3351,6 +3380,7 @@ onMounted(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
rootAbortController.abort();
|
||||||
updateCenterStore.unbind();
|
updateCenterStore.unbind();
|
||||||
window.ipcRenderer.off(
|
window.ipcRenderer.off(
|
||||||
"install-complete",
|
"install-complete",
|
||||||
|
|||||||
Reference in New Issue
Block a user