fix(marketplace): UUID-v4 whitelist on pack_id + stale-response guard (pr_agent #444 round 2)

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。
This commit is contained in:
baiqing committed 2026-05-15 14:48:05 +08:00
1 parent 242f7ef8cd
commit 4af7fec82f
2 files changed
+25 -3

No files matched your search

+17 -1
View File
@@ -2379,6 +2379,9 @@ pub async fn marketplace_detail(
coord: CoordinatorState<'_>,
pack_id: String,
) -> Result<MarketplaceDetail, String> {
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<StylePack, String> {
// 安全校验: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<serde_json::Value, String> {
// 本地 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<serde_json::Value, String> {
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);
+8 -2
View File
@@ -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]);