From 4af7fec82f94223fa77fdd1ad3a529e4db0efcf9 Mon Sep 17 00:00:00 2001 From: baiqing Date: Fri, 15 May 2026 14:48:05 +0800 Subject: [PATCH] fix(marketplace): UUID-v4 whitelist on pack_id + stale-response guard (pr_agent #444 round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pr_agent 提的两个真问题: 1. Security: arbitrary file write —— marketplace_install 用 backend-controlled pack_id 拼临时文件路径。若 backend 被攻陷返回路径遍历 id,可写客户端任意位置。 修:4 个 marketplace_* IPC 全部加 is_valid_session_id() UUID-v4 白名单 校验(跟 read_audio_recording 同套 boundary 校验)。 2. Race condition: 搜索 refresh 旧请求晚到覆盖新结果。用户连续输入时体验差。 修:useRef 单调递增 seq token,response 到了 check seq 是否最新,stale 直接丢。 cargo test 263 全过;tsc 0 error。 --- openless-all/app/src-tauri/src/commands.rs | 18 +++++++++++++++++- openless-all/app/src/pages/Marketplace.tsx | 10 ++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 3072f1ee..ca5cc2cb 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -2379,6 +2379,9 @@ pub async fn marketplace_detail( coord: CoordinatorState<'_>, pack_id: String, ) -> Result { + if !is_valid_session_id(&pack_id) { + return Err("invalid pack id".into()); + } let prefs = coord.prefs().get(); let base = marketplace_url_from_prefs(&prefs); let client = reqwest::Client::new(); @@ -2402,6 +2405,12 @@ pub async fn marketplace_install( coord: CoordinatorState<'_>, pack_id: String, ) -> Result { + // 安全校验:pack_id 来自远端 backend,可能含路径遍历 segment。 + // 用跟 read_audio_recording 同样的 UUID-v4 白名单挡住 ../ / 绝对路径等。 + // backend 当前用 Uuid::new_v4 生成所有 id,合法 id 必然匹配。 + if !is_valid_session_id(&pack_id) { + return Err("invalid pack id".into()); + } let prefs = coord.prefs().get(); let base = marketplace_url_from_prefs(&prefs); let client = reqwest::Client::new(); @@ -2417,7 +2426,7 @@ pub async fn marketplace_install( .await .map_err(|e| format!("read body failed: {e}"))?; - // 写到临时文件后调既有 import_from_zip(复用本地 ZIP 解析逻辑) + // pack_id 已经过 UUID 白名单,拼临时文件路径安全。 let tmp = std::env::temp_dir().join(format!("openless-marketplace-{pack_id}.zip")); std::fs::write(&tmp, &bytes).map_err(|e| format!("write tmp zip: {e}"))?; let result = coord @@ -2433,6 +2442,10 @@ pub async fn marketplace_upload( coord: CoordinatorState<'_>, pack_id: String, ) -> Result { + // 本地 style pack id 也是 Uuid::new_v4 字面,跟远端同形态。挡 path traversal。 + if !is_valid_session_id(&pack_id) { + return Err("invalid pack id".into()); + } let prefs = coord.prefs().get(); let base = marketplace_url_from_prefs(&prefs); let dev_user = marketplace_dev_user(&prefs); @@ -2478,6 +2491,9 @@ pub async fn marketplace_like( coord: CoordinatorState<'_>, pack_id: String, ) -> Result { + if !is_valid_session_id(&pack_id) { + return Err("invalid pack id".into()); + } let prefs = coord.prefs().get(); let base = marketplace_url_from_prefs(&prefs); let dev_user = marketplace_dev_user(&prefs); diff --git a/openless-all/app/src/pages/Marketplace.tsx b/openless-all/app/src/pages/Marketplace.tsx index ce4a23b2..f3704536 100644 --- a/openless-all/app/src/pages/Marketplace.tsx +++ b/openless-all/app/src/pages/Marketplace.tsx @@ -11,7 +11,7 @@ // 用户在 Settings 填生产 URL 后客户端自动切换。 // dev 上传需要 prefs.marketplaceDevLogin(GitHub login 风格)—— 空时上传按钮 disabled。 -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; import { @@ -53,17 +53,23 @@ export function Marketplace() { return () => window.clearTimeout(id); }, [query]); + // 单调递增 seq 防 stale 响应覆盖:用户快速改 query 时旧请求的 response + // 可能晚于新请求到达,比较 seq 丢弃过期结果。 + const reqSeqRef = useRef(0); const refresh = useCallback(async () => { + const seq = ++reqSeqRef.current; setLoading(true); setLoadError(null); try { const list = await listMarketplace({ query: debouncedQuery, sort, limit: 50 }); + if (seq !== reqSeqRef.current) return; // stale response setItems(list); } catch (error) { + if (seq !== reqSeqRef.current) return; console.error('[marketplace] list failed', error); setLoadError(errorMessage(error)); } finally { - setLoading(false); + if (seq === reqSeqRef.current) setLoading(false); } }, [debouncedQuery, sort]);