diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09e180adb..d6fbf9e02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,10 +24,12 @@ jobs: node-version: "20" - name: Setup pnpm - uses: pnpm/action-setup@v6 - with: - version: 10.12.3 - run_install: false + env: + COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" + run: | + corepack enable + corepack install + pnpm --version - name: Get pnpm store directory id: pnpm-store diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dde229900..ad5137a39 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -83,19 +83,11 @@ jobs: || sudo apt-get install -y --no-install-recommends libsoup2.4-dev - name: Setup pnpm - if: runner.os != 'Windows' || matrix.arch != 'arm64' - uses: pnpm/action-setup@v6 - with: - version: 10.12.3 - run_install: false - - - name: Setup pnpm (Windows ARM64) - if: runner.os == 'Windows' && matrix.arch == 'arm64' - shell: pwsh + env: + COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" run: | - $ErrorActionPreference = 'Stop' corepack enable - corepack prepare pnpm@10.12.3 --activate + corepack install node --version pnpm --version diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cdf0dfaf3..f764a89e2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,6 +24,14 @@ There are many ways to contribute: - Rust 1.85+ and Cargo - [Tauri 2.0 prerequisites](https://v2.tauri.app/start/prerequisites/) +> **pnpm version**: the exact version is pinned via the `packageManager` field +> in `package.json` (and used by CI, which sets pnpm up through Corepack). +> With Corepack enabled locally (`corepack enable`), your pnpm shim switches +> to the pinned version automatically, with a one-time download on first use; +> without Corepack, any pnpm 8+ keeps working (`package-manager-strict-version` +> defaults to off). To upgrade pnpm, edit the `packageManager` field — Dependabot +> bumps no longer cover the version. + ### Quick Start ```bash @@ -152,6 +160,12 @@ CC Switch supports three languages. When modifying user-facing text: - Rust 1.85+ 和 Cargo - [Tauri 2.0 开发环境](https://v2.tauri.app/start/prerequisites/) +> **pnpm 版本**:确切版本由 `package.json` 的 `packageManager` 字段固定 +> (CI 通过 Corepack 按此字段安装)。本地启用 Corepack(`corepack enable`) +> 后,pnpm shim 会自动切到该版本(首次使用需一次性下载);未启用 Corepack +> 时任意 pnpm 8+ 均可正常使用(`package-manager-strict-version` 默认关闭)。 +> 升级 pnpm 请直接修改 `packageManager` 字段,不再由 Dependabot 代劳。 + ### 快速开始 ```bash diff --git a/package.json b/package.json index cda519685..3942cc444 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "keywords": [], "author": "Jason Young", "license": "MIT", + "packageManager": "pnpm@10.12.3", "devDependencies": { "@tauri-apps/cli": "^2.8.0", "@testing-library/jest-dom": "^6.6.3", diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index 851936aa0..5e5efb4df 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -2,8 +2,8 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; use crate::config::{ - atomic_write, delete_file, get_home_dir, read_json_file, sanitize_provider_name, - write_json_file, write_text_file, + atomic_write, delete_file, get_home_dir, path_is_within, read_json_file, + sanitize_provider_name, write_json_file, write_text_file, }; use crate::error::AppError; use crate::model_capabilities::{image_input_capability_from_modalities, ImageInputCapability}; @@ -1359,17 +1359,28 @@ pub fn prepare_codex_config_text_with_model_catalog( /// All failure modes (missing file, parse error, no `model_catalog_json`, /// entries without `slug`) collapse to `Ok(None)` so callers can treat this /// as best-effort enrichment without making `read_live_settings` brittle. +/// 模型目录文件读取上限(32 MiB)。目录 JSON 正常只有几百 KiB;超过则视为异常, +/// 避免指向外部大文件时耗尽内存。 +const MAX_CODEX_CATALOG_BYTES: u64 = 32 * 1024 * 1024; + pub fn read_codex_model_catalog_simplified_from_live() -> Result, AppError> { let config_text = read_codex_config_text()?; - let generated_path = get_codex_model_catalog_path(); - let Some(catalog_path) = resolve_cc_switch_catalog_path(&config_text, &generated_path) else { + let config_dir = get_codex_config_dir(); + let Some(catalog_path) = resolve_cc_switch_catalog_path(&config_text, &config_dir) else { return Ok(None); }; if !catalog_path.exists() { return Ok(None); } - let Ok(catalog_text) = fs::read_to_string(&catalog_path) else { - return Ok(None); + let catalog_text = match read_limited_string(&catalog_path, MAX_CODEX_CATALOG_BYTES) { + Ok(text) => text, + Err(error) => { + log::warn!( + "拒绝读取越界或过大的 Codex 模型目录 {}: {error}", + catalog_path.display() + ); + return Ok(None); + } }; Ok(build_simplified_catalog_from_texts( &config_text, @@ -1377,12 +1388,31 @@ pub fn read_codex_model_catalog_simplified_from_live() -> Result, )) } +/// 安全地读取文件为字符串,并在超过字节上限时返回错误。 +pub(crate) fn read_limited_string(path: &Path, max_bytes: u64) -> Result { + let metadata = fs::metadata(path).map_err(|error| AppError::io(path, error))?; + if metadata.len() > max_bytes { + return Err(AppError::Config(format!( + "文件 {} 超过大小上限 {} 字节", + path.display(), + max_bytes + ))); + } + fs::read_to_string(path).map_err(|error| AppError::io(path, error)) +} + +/// Read the cc-switch Codex model catalog file with a size cap. +pub(crate) fn read_codex_model_catalog_text(path: &Path) -> Result { + read_limited_string(path, MAX_CODEX_CATALOG_BYTES) +} + /// Given `config.toml` text, resolve the on-disk path of the cc-switch–owned /// catalog file (returns `None` if `model_catalog_json` is absent or points at -/// a file we don't own). Relative paths fall back to `generated_path`. +/// a file we don't own). Relative paths are resolved under `base_dir`; +/// absolute paths must still be inside `base_dir`. pub(crate) fn resolve_cc_switch_catalog_path( config_text: &str, - generated_path: &Path, + base_dir: &Path, ) -> Option { if config_text.trim().is_empty() { return None; @@ -1401,11 +1431,59 @@ pub(crate) fn resolve_cc_switch_catalog_path( return None; } - if referenced_path.is_absolute() { - Some(referenced_path.to_path_buf()) + // 注意(有意的行为变更):Windows 上 `/…` 形式的旧 WSL 风格 Linux 路径也会 + // 被视为绝对路径,从而在下方的包含性校验中失败——此前这类路径会因无法匹配 + // 生成文件名而回退为按文件名解析、碰巧能工作。可接受:下一次切换供应商时 + // 写入侧会重新落一个裸文件名,配置自愈(见 + // `set_catalog_json_none_removes_cc_switch_owned_by_filename` 的场景注释)。 + let is_unix_absolute = catalog_path_str.starts_with('/'); + let resolved = if referenced_path.is_absolute() || is_unix_absolute { + referenced_path.to_path_buf() } else { - Some(generated_path.to_path_buf()) + base_dir.join(referenced_path) + }; + + if !path_is_within(base_dir, &resolved) { + log::warn!( + "Codex model_catalog_json 指向配置目录外: {}(允许目录: {})", + resolved.display(), + base_dir.display() + ); + return None; } + + // 词法包含不等于运行时包含:配置目录内的符号链接(如 ~/.codex/link -> + // /etc)能让 `link/cc-switch-model-catalog.json` 通过上面的检查,读取却 + // 落到目录外。文件存在时把真实路径 canonicalize 出来再校验一次,并把 + // canonical 路径返回给调用方——后续读取不再经过 symlink 组件。 + if resolved.exists() { + let canonical = match fs::canonicalize(&resolved) { + Ok(path) => path, + Err(error) => { + log::warn!( + "Codex model_catalog_json canonicalize 失败: {}: {error}", + resolved.display() + ); + return None; + } + }; + // base 同样 canonicalize,保证两侧前缀一致(Windows \\?\、 + // macOS /tmp -> /private/tmp);base 失败时退回词法 base—— + // 词法 base 与 canonical 路径比较只会误拒(退化为不读),不会误放。 + let canonical_base = fs::canonicalize(base_dir).unwrap_or_else(|_| base_dir.to_path_buf()); + if !path_is_within(&canonical_base, &canonical) { + log::warn!( + "Codex model_catalog_json 经符号链接解析到配置目录外: {} -> {}(允许目录: {})", + resolved.display(), + canonical.display(), + canonical_base.display() + ); + return None; + } + return Some(canonical); + } + + Some(resolved) } /// Pure reverse-parsing core: convert Codex catalog JSON text back into the @@ -3851,30 +3929,30 @@ web_search = "disabled" #[test] fn resolve_catalog_path_returns_none_when_config_missing_field() { - let generated = PathBuf::from("/tmp/.codex/cc-switch-model-catalog.json"); - assert!(resolve_cc_switch_catalog_path("", &generated).is_none()); + let base = PathBuf::from("/tmp/.codex"); + assert!(resolve_cc_switch_catalog_path("", &base).is_none()); assert!( - resolve_cc_switch_catalog_path("model = \"gpt-5\"", &generated).is_none(), + resolve_cc_switch_catalog_path("model = \"gpt-5\"", &base).is_none(), "no model_catalog_json field should yield None" ); } #[test] fn resolve_catalog_path_accepts_cc_switch_owned_file() { - let generated = PathBuf::from("/tmp/.codex/cc-switch-model-catalog.json"); + let base = PathBuf::from("/tmp/.codex"); let config = r#"model_catalog_json = "/tmp/.codex/cc-switch-model-catalog.json" "#; - let resolved = resolve_cc_switch_catalog_path(config, &generated).expect("path resolves"); - assert_eq!(resolved, generated); + let resolved = resolve_cc_switch_catalog_path(config, &base).expect("path resolves"); + assert_eq!(resolved, base.join(CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME)); } #[test] fn resolve_catalog_path_rejects_user_owned_external_file() { - let generated = PathBuf::from("/tmp/.codex/cc-switch-model-catalog.json"); + let base = PathBuf::from("/tmp/.codex"); let config = r#"model_catalog_json = "/Users/me/.codex/my-handwritten-catalog.json" "#; assert!( - resolve_cc_switch_catalog_path(config, &generated).is_none(), + resolve_cc_switch_catalog_path(config, &base).is_none(), "external catalog files should be left alone" ); } @@ -4223,36 +4301,109 @@ model = "glm-5" let config_text = r#"model_provider = "custom" model_catalog_json = "cc-switch-model-catalog.json" "#; - let generated_path = PathBuf::from("/home/user/.codex/cc-switch-model-catalog.json"); - let result = resolve_cc_switch_catalog_path(config_text, &generated_path); + let base_dir = PathBuf::from("/home/user/.codex"); + let result = resolve_cc_switch_catalog_path(config_text, &base_dir); assert_eq!( result, - Some(generated_path), - "relative filename should resolve to generated_path for file I/O" + Some(base_dir.join(CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME)), + "relative filename should resolve under base_dir for file I/O" ); } #[test] - fn resolve_catalog_ignores_user_owned_relative() { - let config_text = r#"model_catalog_json = "my-custom-catalog.json" + fn resolve_catalog_rejects_absolute_path_outside_config_dir() { + let config_text = r#"model_catalog_json = "/tmp/secret/cc-switch-model-catalog.json" "#; - let generated_path = PathBuf::from("/home/user/.codex/cc-switch-model-catalog.json"); - let result = resolve_cc_switch_catalog_path(config_text, &generated_path); + let base_dir = PathBuf::from("/home/user/.codex"); + let result = resolve_cc_switch_catalog_path(config_text, &base_dir); assert_eq!( result, None, - "user-owned catalog should not be claimed by cc-switch" + "absolute path outside ~/.codex must not be accepted" ); } #[test] - fn set_catalog_json_none_removes_relative_path() { - let input = r#"model_catalog_json = "cc-switch-model-catalog.json" + fn resolve_catalog_accepts_absolute_path_inside_config_dir() { + let config_text = r#"model_catalog_json = "/home/user/.codex/cc-switch-model-catalog.json" "#; - let result = set_codex_model_catalog_json_field(input, None).unwrap(); - let parsed: toml::Value = toml::from_str(&result).unwrap(); + let base_dir = PathBuf::from("/home/user/.codex"); + let result = resolve_cc_switch_catalog_path(config_text, &base_dir); + assert_eq!( + result, + Some(base_dir.join(CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME)), + "absolute path inside ~/.codex should be accepted" + ); + } + + #[test] + fn resolve_catalog_rejects_traversal_to_parent_directory() { + let config_text = r#"model_catalog_json = "../cc-switch-model-catalog.json" +"#; + let base_dir = PathBuf::from("/home/user/.codex"); + let result = resolve_cc_switch_catalog_path(config_text, &base_dir); + assert_eq!( + result, None, + "relative traversal outside ~/.codex must not be accepted" + ); + } + + #[test] + fn resolve_catalog_rejects_symlink_escaping_config_dir() { + // 词法包含可被符号链接绕过:~/.codex/link -> 外部目录, + // "link/cc-switch-model-catalog.json" 词法上在 base 内,真实读取却落到 + // base 外。canonicalize 之后的二次校验必须拒绝。 + let temp = tempfile::tempdir().expect("tempdir"); + let base_dir = temp.path().join("codex"); + let outside_dir = temp.path().join("outside"); + fs::create_dir_all(&base_dir).expect("create base"); + fs::create_dir_all(&outside_dir).expect("create outside"); + let escaped_file = outside_dir.join(CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME); + fs::write(&escaped_file, r#"{"models":[]}"#).expect("write escaped catalog"); + + #[cfg(unix)] + std::os::unix::fs::symlink(&outside_dir, base_dir.join("link")).expect("symlink"); + #[cfg(windows)] + std::os::windows::fs::symlink_dir(&outside_dir, base_dir.join("link")).expect("symlink"); + + let config_text = r#"model_catalog_json = "link/cc-switch-model-catalog.json" +"#; + let result = resolve_cc_switch_catalog_path(config_text, &base_dir); + assert_eq!( + result, None, + "symlink escaping the config dir must be rejected after canonicalization" + ); + } + + #[test] + fn resolve_catalog_accepts_real_file_inside_config_dir() { + // 存在于 base 内的真实文件:canonical 校验通过后仍应接受 + let temp = tempfile::tempdir().expect("tempdir"); + let base_dir = temp.path().join("codex"); + fs::create_dir_all(&base_dir).expect("create base"); + let catalog_file = base_dir.join(CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME); + fs::write(&catalog_file, r#"{"models":[]}"#).expect("write catalog"); + + let config_text = r#"model_catalog_json = "cc-switch-model-catalog.json" +"#; + let result = resolve_cc_switch_catalog_path(config_text, &base_dir); + let resolved = result.expect("real file inside config dir should be accepted"); + assert_eq!( + resolved.file_name().and_then(|n| n.to_str()), + Some(CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME) + ); + } + + #[test] + fn read_limited_string_rejects_oversized_file() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("huge.json"); + let file = std::fs::File::create(&path).expect("create"); + file.set_len(MAX_CODEX_CATALOG_BYTES + 1).expect("set_len"); + + let result = read_limited_string(&path, MAX_CODEX_CATALOG_BYTES); assert!( - parsed.get("model_catalog_json").is_none(), - "None arm should remove relative cc-switch-owned field" + result.is_err(), + "file larger than MAX_CODEX_CATALOG_BYTES must be rejected" ); } } diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index ed4686522..733311323 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -90,6 +90,26 @@ fn path_eq_lexical(left: &Path, right: &Path) -> bool { comparable_path_key(left) == comparable_path_key(right) } +/// Returns true when `path` is lexically contained within `base`. +/// +/// Both paths are normalized lexically (without hitting the filesystem), so +/// this works for non-existent paths. It is **not** a symlink defense: a +/// symlink inside `base` can still lead a resolved path outside it. Callers +/// that go on to open the file must canonicalize the existing path and +/// re-verify containment (see `resolve_cc_switch_catalog_path`). +/// On Windows the comparison is case-insensitive. +pub(crate) fn path_is_within(base: &Path, path: &Path) -> bool { + let base_key = comparable_path_key(base); + let path_key = comparable_path_key(path); + + if path_key == base_key { + return true; + } + + let prefix = format!("{base_key}/"); + path_key.starts_with(&prefix) +} + #[cfg(windows)] fn derive_wsl_default_mcp_path(dir: &Path) -> Option { use std::path::Prefix; diff --git a/src-tauri/src/proxy/content_encoding.rs b/src-tauri/src/proxy/content_encoding.rs index 970ffd289..8db53422d 100644 --- a/src-tauri/src/proxy/content_encoding.rs +++ b/src-tauri/src/proxy/content_encoding.rs @@ -27,55 +27,114 @@ fn is_single_supported(coding: &str) -> bool { ) } -/// 解压单个 content-coding。未知编码返回 `Ok(None)`。 -fn decompress_single(coding: &str, body: &[u8]) -> Result>, std::io::Error> { +/// 解压失败原因。把「输出超预算」与「数据损坏」区分开:前者是安全拒绝信号, +/// 响应侧调用方应据此拒绝响应(502),而不是当成普通解压失败静默回退。 +#[derive(Debug)] +pub(crate) enum DecompressError { + /// 底层解码失败(数据损坏 / 格式不符)。 + Io(std::io::Error), + /// 解压输出超过 `limit` 字节即中止;此时真实输出大小未知,只会大于 limit。 + TooLarge { limit: usize }, +} + +impl std::fmt::Display for DecompressError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(e) => write!(f, "{e}"), + Self::TooLarge { limit } => write!(f, "解压输出超过上限 {limit} 字节"), + } + } +} + +impl std::error::Error for DecompressError {} + +impl From for DecompressError { + fn from(e: std::io::Error) -> Self { + Self::Io(e) + } +} + +impl From for std::io::Error { + fn from(e: DecompressError) -> Self { + match e { + DecompressError::Io(e) => e, + DecompressError::TooLarge { limit } => { + std::io::Error::other(format!("decompressed body exceeds {limit} bytes")) + } + } + } +} + +/// 从解码器读取解压输出,最多 `max_bytes`;一旦输出超过预算立即中止读取并返回 +/// [`DecompressError::TooLarge`] —— 压缩炸弹在预算耗尽处被截停,而不是先在内存里 +/// 完整展开再比较大小。 +fn read_with_output_limit( + reader: R, + max_bytes: usize, +) -> Result, DecompressError> { + // saturating_add:无界调用(max_bytes = usize::MAX)时预算保持 usize::MAX + let budget = max_bytes.saturating_add(1) as u64; + let mut limited = reader.take(budget); + let mut out = Vec::new(); + limited.read_to_end(&mut out)?; + if out.len() > max_bytes { + return Err(DecompressError::TooLarge { limit: max_bytes }); + } + Ok(out) +} + +/// 解压单个 content-coding,输出上限 `max_output_bytes`。未知编码返回 `Ok(None)`。 +fn decompress_single( + coding: &str, + body: &[u8], + max_output_bytes: usize, +) -> Result>, DecompressError> { match coding { "gzip" | "x-gzip" => { - let mut decoder = flate2::read::GzDecoder::new(body); - let mut decompressed = Vec::new(); - decoder.read_to_end(&mut decompressed)?; - Ok(Some(decompressed)) + let decoder = flate2::read::GzDecoder::new(body); + Ok(Some(read_with_output_limit(decoder, max_output_bytes)?)) } "deflate" => { // RFC 9110: deflate 指 zlib 包裹格式;但部分上游 / 客户端发 raw deflate 流。 // 先按规范尝试 zlib,失败再回退 raw —— 否则合规来源必然解压失败, // 原始压缩字节会被 fail-open 透传给 JSON 解析(#2234 形态 C 之一)。 - let mut decompressed = Vec::new(); - let mut zlib = flate2::read::ZlibDecoder::new(body); - match zlib.read_to_end(&mut decompressed) { - Ok(_) => Ok(Some(decompressed)), + let zlib = flate2::read::ZlibDecoder::new(body); + match read_with_output_limit(zlib, max_output_bytes) { + Ok(decompressed) => Ok(Some(decompressed)), Err(zlib_err) => { + // TooLarge 也要回退:raw 流被误判为 zlib 时可能在预算处截停, + // 回退后若真是炸弹,raw 解码同样会触发 TooLarge。 log::debug!("deflate 按 zlib 解压失败({zlib_err}),回退 raw deflate"); - let mut decompressed = Vec::new(); - let mut raw = flate2::read::DeflateDecoder::new(body); - raw.read_to_end(&mut decompressed)?; - Ok(Some(decompressed)) + let raw = flate2::read::DeflateDecoder::new(body); + Ok(Some(read_with_output_limit(raw, max_output_bytes)?)) } } } "br" => { - let mut decompressed = Vec::new(); - brotli::BrotliDecompress(&mut std::io::Cursor::new(body), &mut decompressed)?; - Ok(Some(decompressed)) + let decoder = brotli::Decompressor::new(std::io::Cursor::new(body), 4096); + Ok(Some(read_with_output_limit(decoder, max_output_bytes)?)) } "zstd" | "zst" => { // Codex 登录态对请求体启用 zstd(Compression::Zstd);上游也可能 zstd 压缩响应。 - let decompressed = zstd::stream::decode_all(std::io::Cursor::new(body))?; - Ok(Some(decompressed)) + let decoder = zstd::stream::read::Decoder::new(std::io::Cursor::new(body))?; + Ok(Some(read_with_output_limit(decoder, max_output_bytes)?)) } _ => Ok(None), } } -/// 根据 content-encoding 解压 body 字节,支持堆叠编码(如 `gzip, zstd`)。 +/// 根据 content-encoding 解压 body 字节,支持堆叠编码(如 `gzip, zstd`), +/// 且每个 coding 的解压输出(含堆叠编码的中间产物)都受 `max_output_bytes` +/// 限制,超限即中止并返回 [`DecompressError::TooLarge`],用于防御响应侧压缩炸弹。 /// /// RFC 9110 §8.4:codings 按**应用顺序**列出,故解压须**反向**(最后应用的先解)。 /// 返回 `Ok(None)` 表示存在不受支持的编码、原样透传——此时调用方必须保留 /// content-encoding 头,否则下游(诊断 / 客户端)会把压缩字节误当明文。 -pub(crate) fn decompress_body( +pub(crate) fn decompress_body_with_limit( content_encoding: &str, body: &[u8], -) -> Result>, std::io::Error> { + max_output_bytes: usize, +) -> Result>, DecompressError> { let codings = split_codings(content_encoding); if codings.is_empty() { return Ok(None); @@ -90,7 +149,7 @@ pub(crate) fn decompress_body( let mut data: Option> = None; for coding in codings.iter().rev() { let input = data.as_deref().unwrap_or(body); - match decompress_single(coding, input)? { + match decompress_single(coding, input, max_output_bytes)? { Some(decompressed) => data = Some(decompressed), // 上面 is_single_supported 已校验,理论不会发生;防御性兜底。 None => return Ok(None), @@ -99,6 +158,15 @@ pub(crate) fn decompress_body( Ok(data) } +/// 无输出上限的 [`decompress_body_with_limit`] 版本,供请求侧等已有自身 +/// 体积约束的调用方使用。 +pub(crate) fn decompress_body( + content_encoding: &str, + body: &[u8], +) -> Result>, std::io::Error> { + decompress_body_with_limit(content_encoding, body, usize::MAX).map_err(Into::into) +} + /// 该 content-encoding(含堆叠,如 `gzip, zstd`)是否全部可被解压。 /// /// 请求侧用它做闸门:无法解压的压缩体不能透传给 JSON 解析,需直接拒绝。 @@ -195,6 +263,116 @@ mod tests { assert!(result.is_none()); } + /// 生成确定性伪随机字节(LCG),避免测试引入 rand 依赖。 + fn pseudo_random_bytes(len: usize) -> Vec { + let mut state: u64 = 0x243F_6A88_85A3_08D3; + (0..len) + .map(|_| { + state = state + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (state >> 33) as u8 + }) + .collect() + } + + fn gzip_compress(payload: &[u8]) -> Vec { + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + std::io::Write::write_all(&mut encoder, payload).unwrap(); + encoder.finish().unwrap() + } + + #[test] + fn decompress_body_with_limit_passes_payload_under_limit() { + let payload = br#"{"ok":true}"#; + let compressed = gzip_compress(payload); + + let out = decompress_body_with_limit("gzip", &compressed, 1024) + .unwrap() + .unwrap(); + assert_eq!(out, payload); + } + + #[test] + fn decompress_body_with_limit_allows_exactly_limit_bytes() { + let payload = vec![7u8; 64 * 1024]; + let compressed = gzip_compress(&payload); + + let out = decompress_body_with_limit("gzip", &compressed, 64 * 1024) + .unwrap() + .unwrap(); + assert_eq!(out.len(), 64 * 1024); + assert_eq!(out, payload); + } + + #[test] + fn decompress_body_with_limit_aborts_gzip_bomb_mid_stream() { + // 4 MiB 伪随机数据(压缩率约 1:1)gzip 后截断到 2 MiB:流在产出约 2 MiB + // 解压数据后 abrupt 结束。有界读取应在 1 MiB 预算耗尽处报 TooLarge; + // 无界读取会一路读到残缺的流尾报 UnexpectedEof(Io)——两者可区分, + // 因此该测试能识别"先完整展开再比较"的退化。 + let payload = pseudo_random_bytes(4 * 1024 * 1024); + let compressed = gzip_compress(&payload); + assert!(compressed.len() > 2 * 1024 * 1024); + let truncated = &compressed[..2 * 1024 * 1024]; + + let result = decompress_body_with_limit("gzip", truncated, 1024 * 1024); + assert!( + matches!(result, Err(DecompressError::TooLarge { .. })), + "应在预算耗尽处截停(TooLarge),而不是读到流尾才报错: {:?}", + result.as_ref().map(|o| o.as_ref().map(Vec::len)) + ); + } + + #[test] + fn decompress_body_with_limit_rejects_zstd_bomb() { + // 高压缩比 payload:8 MiB 全零 → zstd 压缩后仅数 KiB,完整展开必然超限 + let payload = vec![0u8; 8 * 1024 * 1024]; + let compressed = zstd::stream::encode_all(std::io::Cursor::new(&payload[..]), 0).unwrap(); + assert!(compressed.len() < 1024 * 1024); + + let result = decompress_body_with_limit("zstd", &compressed, 1024 * 1024); + assert!( + matches!(result, Err(DecompressError::TooLarge { .. })), + "zstd 压缩炸弹应在预算耗尽处截停: {:?}", + result.as_ref().map(|o| o.as_ref().map(Vec::len)) + ); + } + + #[test] + fn decompress_body_with_limit_rejects_brotli_bomb() { + let payload = vec![0u8; 8 * 1024 * 1024]; + let mut compressed = Vec::new(); + { + let mut writer = brotli::CompressorWriter::new(&mut compressed, 4096, 5, 22); + std::io::Write::write_all(&mut writer, &payload).unwrap(); + } + assert!(compressed.len() < 1024 * 1024); + + let result = decompress_body_with_limit("br", &compressed, 1024 * 1024); + assert!( + matches!(result, Err(DecompressError::TooLarge { .. })), + "brotli 压缩炸弹应在预算耗尽处截停: {:?}", + result.as_ref().map(|o| o.as_ref().map(Vec::len)) + ); + } + + #[test] + fn decompress_body_with_limit_bounds_intermediate_stage_of_stacked_encodings() { + // 堆叠编码 gzip, zstd:zstd 先解出 gzip 流(小),gzip 再展开成 8 MiB。 + // 中间产物同样受预算约束,不能只在最后一级设防。 + let payload = vec![0u8; 8 * 1024 * 1024]; + let gzipped = gzip_compress(&payload); + let stacked = zstd::stream::encode_all(std::io::Cursor::new(&gzipped[..]), 0).unwrap(); + + let result = decompress_body_with_limit("gzip, zstd", &stacked, 1024 * 1024); + assert!( + matches!(result, Err(DecompressError::TooLarge { .. })), + "堆叠编码的中间解压产物也应受预算约束: {:?}", + result.as_ref().map(|o| o.as_ref().map(Vec::len)) + ); + } + #[test] fn is_supported_content_encoding_matches_decompressable() { for enc in [ diff --git a/src-tauri/src/proxy/error.rs b/src-tauri/src/proxy/error.rs index 5468c445e..8e0a67f28 100644 --- a/src-tauri/src/proxy/error.rs +++ b/src-tauri/src/proxy/error.rs @@ -8,6 +8,9 @@ use thiserror::Error; #[derive(Debug, Error)] pub enum ProxyError { + #[error("上游响应体超过大小上限: {0} 字节")] + ResponseBodyTooLarge(usize), + #[error("服务器已在运行")] AlreadyRunning, @@ -156,6 +159,9 @@ impl IntoResponse for ProxyError { ProxyError::Internal(_) => { (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()) } + ProxyError::ResponseBodyTooLarge(_) => { + (StatusCode::BAD_GATEWAY, self.to_string()) + } ProxyError::UpstreamError { .. } => unreachable!(), }; diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index fdc82bf0d..ace793e4f 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -2,10 +2,10 @@ //! //! 负责将请求转发到上游Provider,支持故障转移 -use super::hyper_client::ProxyResponse; +use super::hyper_client::{ProxyResponse, MAX_RESPONSE_BODY_BYTES}; use super::{ body_filter::filter_private_params_with_whitelist, - content_encoding::{decompress_body, get_content_encoding}, + content_encoding::{decompress_body_with_limit, get_content_encoding}, error::*, failover_switch::FailoverSwitchManager, json_canonical::{canonicalize_value, short_value_hash}, @@ -2317,13 +2317,16 @@ impl RequestForwarder { // 自动解压 feature,这里拿到的是原始字节;不解压的话,压缩过的错误体会 // 在 from_utf8 处变成非 UTF-8 而被丢弃,隐藏掉上游的限流/鉴权等详情。 let encoding = get_content_encoding(response.headers()); - let raw = response.bytes().await?; + let raw = response.bytes_with_limit(MAX_RESPONSE_BODY_BYTES).await?; let decoded = match encoding { - Some(encoding) => match decompress_body(&encoding, &raw) { - Ok(Some(decompressed)) => decompressed, - // 不支持的编码 / 解压失败:退回原始字节,尽量保留可读信息 - _ => raw.to_vec(), - }, + Some(encoding) => { + match decompress_body_with_limit(&encoding, &raw, MAX_RESPONSE_BODY_BYTES) { + Ok(Some(decompressed)) => decompressed, + // 不支持的编码 / 解压失败 / 解压后超限:退回(已有上限的) + // 原始字节,尽量保留可读信息 + _ => raw.to_vec(), + } + } None => raw.to_vec(), }; let body_text = String::from_utf8(decoded).ok(); @@ -2355,14 +2358,17 @@ impl RequestForwarder { let status = response.status(); let headers = response.headers().clone(); let body_timeout = self.non_streaming_timeout; - let body = tokio::time::timeout(body_timeout, response.bytes()) - .await - .map_err(|_| { - ProxyError::Timeout(format!( - "响应体读取超时: {}s(上游发完响应头后 body 未到达)", - body_timeout.as_secs() - )) - })??; + let body = tokio::time::timeout( + body_timeout, + response.bytes_with_limit(MAX_RESPONSE_BODY_BYTES), + ) + .await + .map_err(|_| { + ProxyError::Timeout(format!( + "响应体读取超时: {}s(上游发完响应头后 body 未到达)", + body_timeout.as_secs() + )) + })??; Ok(ProxyResponse::buffered(status, headers, body)) } @@ -2377,12 +2383,14 @@ impl RequestForwarder { let status = response.status(); let headers = response.headers().clone(); let encoding = get_content_encoding(&headers); - let raw = response.bytes().await?; + let raw = response.bytes_with_limit(MAX_RESPONSE_BODY_BYTES).await?; let decoded = match encoding { - Some(encoding) => match decompress_body(&encoding, &raw) { - Ok(Some(decompressed)) => decompressed, - _ => raw.to_vec(), - }, + Some(encoding) => { + match decompress_body_with_limit(&encoding, &raw, MAX_RESPONSE_BODY_BYTES) { + Ok(Some(decompressed)) => decompressed, + _ => raw.to_vec(), + } + } None => raw.to_vec(), }; @@ -2402,12 +2410,14 @@ impl RequestForwarder { let status = response.status(); let headers = response.headers().clone(); let encoding = get_content_encoding(&headers); - let raw = response.bytes().await?; + let raw = response.bytes_with_limit(MAX_RESPONSE_BODY_BYTES).await?; let decoded = match encoding { - Some(encoding) => match decompress_body(&encoding, &raw) { - Ok(Some(decompressed)) => decompressed, - _ => raw.to_vec(), - }, + Some(encoding) => { + match decompress_body_with_limit(&encoding, &raw, MAX_RESPONSE_BODY_BYTES) { + Ok(Some(decompressed)) => decompressed, + _ => raw.to_vec(), + } + } None => raw.to_vec(), }; @@ -3893,7 +3903,10 @@ mod tests { .expect("response should be buffered"); assert_eq!( - prepared.bytes().await.unwrap(), + prepared + .bytes_with_limit(MAX_RESPONSE_BODY_BYTES) + .await + .unwrap(), Bytes::from_static(b"{\"ok\":true}") ); } @@ -3938,7 +3951,10 @@ mod tests { .expect("stream should be primed"); assert_eq!( - prepared.bytes().await.unwrap(), + prepared + .bytes_with_limit(MAX_RESPONSE_BODY_BYTES) + .await + .unwrap(), Bytes::from_static(b"firstsecond") ); } diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index bb2606486..7c20bd540 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -81,10 +81,10 @@ pub async fn get_status(State(state): State) -> Result Result, ProxyError> { - let generated_path = crate::codex_config::get_codex_model_catalog_path(); + let config_dir = crate::codex_config::get_codex_config_dir(); let active_catalog_path = match crate::codex_config::read_codex_config_text() { Ok(config_text) => { - crate::codex_config::resolve_cc_switch_catalog_path(&config_text, &generated_path) + crate::codex_config::resolve_cc_switch_catalog_path(&config_text, &config_dir) } Err(_) => None, }; @@ -92,8 +92,13 @@ pub async fn handle_models() -> Result, ProxyError> { let catalog = if let Some(catalog_path) = active_catalog_path.as_ref().filter(|path| path.exists()) { - let text = std::fs::read_to_string(catalog_path).unwrap_or_default(); - serde_json::from_str(&text).unwrap_or(json!({"models": []})) + match crate::codex_config::read_codex_model_catalog_text(catalog_path) { + Ok(text) => serde_json::from_str(&text).unwrap_or(json!({"models": []})), + Err(error) => { + log::warn!("[models] 拒绝读取越界或过大的目录文件: {error}"); + json!({"models": []}) + } + } } else { if active_catalog_path.is_none() { log::debug!( @@ -1896,7 +1901,8 @@ fn codex_proxy_error_code(error: &ProxyError) -> &'static str { | ProxyError::NotRunning | ProxyError::BindFailed(_) | ProxyError::StopTimeout - | ProxyError::StopFailed(_) => "cc_switch_proxy_error", + | ProxyError::StopFailed(_) + | ProxyError::ResponseBodyTooLarge(_) => "cc_switch_proxy_error", } } diff --git a/src-tauri/src/proxy/hyper_client.rs b/src-tauri/src/proxy/hyper_client.rs index 7bf3a944b..ac7f61b57 100644 --- a/src-tauri/src/proxy/hyper_client.rs +++ b/src-tauri/src/proxy/hyper_client.rs @@ -72,6 +72,10 @@ fn global_hyper_client() -> &'static HyperClient { }) } +/// 响应体读取上限(128 MiB)。正常非流式补全响应只有几十到几百 KiB;超过则视为 +/// 上游异常或恶意 payload,直接拒绝,避免代理进程被超大响应体/压缩炸弹耗尽内存。 +pub(crate) const MAX_RESPONSE_BODY_BYTES: usize = 128 * 1024 * 1024; + /// Unified response wrapper that can hold either a hyper or reqwest response. /// /// The hyper variant is used for the main (direct) path with header-case preservation. @@ -157,25 +161,31 @@ impl ProxyResponse { .unwrap_or(false) } - /// Consume the response and collect the full body into `Bytes`. - pub async fn bytes(self) -> Result { + /// Consume the response and collect the full body into `Bytes`, aborting the + /// read as soon as the accumulated body exceeds `max_bytes`. + /// + /// 所有变体都在累积过程中逐块检查、超限即断开(drop stream 中止上游连接), + /// 而不是先收满再比较——否则超大明文 body 仍会完整进入内存,限制形同虚设。 + pub async fn bytes_with_limit(self, max_bytes: usize) -> Result { match self { - Self::Hyper(r) => { - let collected = r.into_body().collect().await.map_err(|e| { - ProxyError::ForwardFailed(format!("Failed to read response body: {e}")) - })?; - Ok(collected.to_bytes()) + Self::Buffered { body, .. } => { + // 调用方已把 body 完整缓冲,无法中途截停,只能事后比较 + if body.len() > max_bytes { + return Err(ProxyError::ResponseBodyTooLarge(body.len())); + } + Ok(body) } - Self::Reqwest(r) => r.bytes().await.map_err(|e| { - ProxyError::ForwardFailed(format!("Failed to read response body: {e}")) - }), - Self::Buffered { body, .. } => Ok(body), - Self::Streamed { mut stream, .. } => { + response => { + // Hyper / Reqwest / Streamed 统一走逐块流式累积,超预算立即报错 + let mut stream = response.bytes_stream(); let mut body = bytes::BytesMut::new(); while let Some(chunk) = stream.next().await { let chunk = chunk.map_err(|e| { ProxyError::ForwardFailed(format!("Failed to read response body: {e}")) })?; + if body.len() + chunk.len() > max_bytes { + return Err(ProxyError::ResponseBodyTooLarge(body.len() + chunk.len())); + } body.extend_from_slice(&chunk); } Ok(body.freeze()) @@ -780,4 +790,153 @@ mod tests { assert!(!buffered_with_content_type(Some("text/event-stream")).is_json()); assert!(!buffered_with_content_type(None).is_json()); } + + #[tokio::test] + async fn bytes_with_limit_rejects_oversized_buffered_response() { + let oversized = Bytes::from(vec![0u8; MAX_RESPONSE_BODY_BYTES + 1]); + let response = + ProxyResponse::buffered(http::StatusCode::OK, http::HeaderMap::new(), oversized); + + let result = response.bytes_with_limit(MAX_RESPONSE_BODY_BYTES).await; + assert!(matches!(result, Err(ProxyError::ResponseBodyTooLarge(_)))); + } + + #[tokio::test] + async fn bytes_with_limit_rejects_oversized_streamed_response() { + let (tx, rx) = tokio::sync::mpsc::channel::>(2); + let stream = futures::stream::unfold(rx, |mut rx| async move { + rx.recv().await.map(|item| (item, rx)) + }); + let response = + ProxyResponse::streamed(http::StatusCode::OK, http::HeaderMap::new(), stream); + + tokio::spawn(async move { + let _ = tx.send(Ok(Bytes::from(vec![0u8; 64 * 1024]))).await; + let _ = tx + .send(Ok(Bytes::from(vec![0u8; MAX_RESPONSE_BODY_BYTES]))) + .await; + }); + + let result = response.bytes_with_limit(MAX_RESPONSE_BODY_BYTES).await; + assert!(matches!(result, Err(ProxyError::ResponseBodyTooLarge(_)))); + } + + /// 启动一个最小 HTTP/1.1 服务器:响应 `Content-Length: body_len` 的全零 body, + /// 分块写出并统计实际写成功的字节数(客户端断开后写入失败即停)。 + async fn spawn_fixed_body_server( + body_len: usize, + ) -> (u16, std::sync::Arc) { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let written = Arc::new(AtomicUsize::new(0)); + let written_report = written.clone(); + + tokio::spawn(async move { + let Ok((mut socket, _)) = listener.accept().await else { + return; + }; + // 读完请求头(内容不重要) + let mut buf = [0u8; 4096]; + let mut filled = 0; + loop { + if buf[..filled].windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + let Ok(n) = socket.read(&mut buf[filled..]).await else { + return; + }; + if n == 0 { + return; + } + filled += n; + } + let header = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/octet-stream\r\ncontent-length: {body_len}\r\nconnection: close\r\n\r\n" + ); + if socket.write_all(header.as_bytes()).await.is_err() { + return; + } + let chunk = [0u8; 16 * 1024]; + let mut remaining = body_len; + while remaining > 0 { + let n = remaining.min(chunk.len()); + if socket.write_all(&chunk[..n]).await.is_err() { + break; + } + written.fetch_add(n, Ordering::SeqCst); + remaining -= n; + } + }); + + (port, written_report) + } + + /// 客户端断开到服务器写入失败之间有时延(loopback 缓冲区会再吞一部分), + /// 稍等再读计数。只要客户端真的中途截停,服务器绝不可能写出大半个 body。 + async fn assert_server_aborted_early( + written: std::sync::Arc, + body_len: usize, + ) { + use std::sync::atomic::Ordering; + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + let written = written.load(Ordering::SeqCst); + assert!( + written < body_len / 2, + "客户端应在预算耗尽后立即断开,服务器不应写出大部分 body(实际已写 {written}/{body_len} 字节)" + ); + } + + #[tokio::test] + async fn bytes_with_limit_aborts_hyper_response_before_full_body_arrives() { + const BODY_LEN: usize = 16 * 1024 * 1024; + const LIMIT: usize = 64 * 1024; + let (port, written) = spawn_fixed_body_server(BODY_LEN).await; + + // 构造真实的 hyper::Response:手工建立 http1 客户端连接 + let stream = tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .unwrap(); + let io = hyper_util::rt::TokioIo::new(stream); + let (mut sender, conn) = hyper::client::conn::http1::handshake(io).await.unwrap(); + tokio::spawn(async move { + let _ = conn.await; + }); + let request = http::Request::builder() + .uri(format!("http://127.0.0.1:{port}/")) + .body(http_body_util::Empty::::new()) + .unwrap(); + let response = sender.send_request(request).await.unwrap(); + assert_eq!(response.status(), http::StatusCode::OK); + + let result = ProxyResponse::Hyper(response).bytes_with_limit(LIMIT).await; + assert!(matches!(result, Err(ProxyError::ResponseBodyTooLarge(_)))); + + // 关键断言:若退回"先 collect 收满再比较",服务器会把 16 MiB 全部写完 + assert_server_aborted_early(written, BODY_LEN).await; + } + + #[tokio::test] + async fn bytes_with_limit_aborts_reqwest_response_before_full_body_arrives() { + const BODY_LEN: usize = 16 * 1024 * 1024; + const LIMIT: usize = 64 * 1024; + let (port, written) = spawn_fixed_body_server(BODY_LEN).await; + + let response = reqwest::Client::new() + .get(format!("http://127.0.0.1:{port}/")) + .send() + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::OK); + + let result = ProxyResponse::Reqwest(response) + .bytes_with_limit(LIMIT) + .await; + assert!(matches!(result, Err(ProxyError::ResponseBodyTooLarge(_)))); + + assert_server_aborted_early(written, BODY_LEN).await; + } } diff --git a/src-tauri/src/proxy/response_processor.rs b/src-tauri/src/proxy/response_processor.rs index 5402a43f7..45fa41ae5 100644 --- a/src-tauri/src/proxy/response_processor.rs +++ b/src-tauri/src/proxy/response_processor.rs @@ -3,11 +3,11 @@ //! 统一处理流式和非流式 API 响应 use super::{ - content_encoding::{decompress_body, get_content_encoding}, + content_encoding::{decompress_body_with_limit, get_content_encoding, DecompressError}, forwarder::ActiveConnectionGuard, handler_config::{StreamUsageEventFilter, UsageParserConfig}, handler_context::{RequestContext, StreamingTimeoutConfig}, - hyper_client::ProxyResponse, + hyper_client::{ProxyResponse, MAX_RESPONSE_BODY_BYTES}, server::ProxyState, sse::{strip_sse_field, take_sse_block}, usage::parser::TokenUsage, @@ -86,10 +86,11 @@ pub(crate) async fn read_decoded_body( ) -> Result<(HeaderMap, http::StatusCode, Bytes), ProxyError> { let mut headers = response.headers().clone(); let status = response.status(); + let bytes_future = response.bytes_with_limit(MAX_RESPONSE_BODY_BYTES); let raw_bytes = if body_timeout.is_zero() { - response.bytes().await? + bytes_future.await? } else { - tokio::time::timeout(body_timeout, response.bytes()) + tokio::time::timeout(body_timeout, bytes_future) .await .map_err(|_| { ProxyError::Timeout(format!( @@ -111,15 +112,19 @@ pub(crate) async fn read_decoded_body( if let Some(encoding) = get_content_encoding(&headers) { log::debug!("[{tag}] 解压非流式响应: content-encoding={encoding}"); - match decompress_body(&encoding, &raw_bytes) { + match decompress_body_with_limit(&encoding, &raw_bytes, MAX_RESPONSE_BODY_BYTES) { Ok(Some(decompressed)) => { + // 解码器在预算耗尽处即截停,此处必然 ≤ MAX_RESPONSE_BODY_BYTES body_bytes = Bytes::from(decompressed); decoded = true; } // 不支持的编码:原样透传且保留 content-encoding 头, // 让下游诊断/客户端知道这仍是压缩字节 Ok(None) => {} - Err(e) => { + Err(DecompressError::TooLarge { .. }) => { + return Err(ProxyError::ResponseBodyTooLarge(MAX_RESPONSE_BODY_BYTES)); + } + Err(DecompressError::Io(e)) => { log::warn!("[{tag}] 解压失败 ({encoding}): {e},使用原始数据"); } } @@ -884,6 +889,30 @@ mod tests { assert!(!formatted.contains("cookie-secret"), "{formatted}"); } + #[tokio::test] + async fn read_decoded_body_rejects_compressed_bomb_without_full_expansion() { + // 128 MiB+1 全零 payload 的 gzip 只有 ~130 KiB:原始读取上限拦不住它, + // 只有解压侧的有界解码能拒绝。若解码退化为"先完整展开再比较", + // 展开后长度 > MAX_RESPONSE_BODY_BYTES 的 payload 会成功返回(测试失败)。 + let payload = vec![0u8; MAX_RESPONSE_BODY_BYTES + 1]; + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + std::io::Write::write_all(&mut encoder, &payload).unwrap(); + let compressed = encoder.finish().unwrap(); + assert!(compressed.len() < MAX_RESPONSE_BODY_BYTES); + + let mut headers = HeaderMap::new(); + headers.insert("content-encoding", "gzip".parse().unwrap()); + let response = + ProxyResponse::buffered(http::StatusCode::OK, headers, Bytes::from(compressed)); + + let result = read_decoded_body(response, "test", Duration::ZERO).await; + assert!( + matches!(result, Err(ProxyError::ResponseBodyTooLarge(_))), + "压缩炸弹应被拒绝而不是完整展开: {:?}", + result.map(|(_, _, body)| body.len()) + ); + } + #[test] fn test_strip_sse_field_accepts_optional_space() { assert_eq!( diff --git a/src-tauri/src/services/session_usage_grokbuild.rs b/src-tauri/src/services/session_usage_grokbuild.rs index 7f0be931a..94f8b2c02 100644 --- a/src-tauri/src/services/session_usage_grokbuild.rs +++ b/src-tauri/src/services/session_usage_grokbuild.rs @@ -129,20 +129,45 @@ pub fn sync_grokbuild_usage(db: &Database) -> Result Vec { let mut files = Vec::new(); for root in crate::session_manager::providers::grokbuild::session_roots() { - collect_files_named(&root, "updates.jsonl", &mut files); + collect_files_named(&root, "updates.jsonl", &mut files, 0); } files } +/// 单个 updates.jsonl 文件读取上限(50 MiB)。JSONL 单行事件通常几 KiB, +/// 正常活跃会话数月也到不了这个量级;超过则视为异常/恶意文件,跳过。 +const MAX_GROK_FILE_BYTES: u64 = 50 * 1024 * 1024; +/// 递归收集 session 日志时的最大目录深度,防止 symlink 循环导致栈溢出。 +const MAX_COLLECT_DEPTH: usize = 16; + /// 递归收集目录下指定文件名的文件(容忍布局深度变化,对齐会话浏览器的做法) -fn collect_files_named(root: &Path, name: &str, files: &mut Vec) { +fn collect_files_named(root: &Path, name: &str, files: &mut Vec, depth: usize) { + if depth > MAX_COLLECT_DEPTH { + log::warn!( + "Grok session directory traversal exceeded max depth {} at {}", + MAX_COLLECT_DEPTH, + root.display() + ); + return; + } let Ok(entries) = fs::read_dir(root) else { return; }; for entry in entries.flatten() { let path = entry.path(); - if path.is_dir() { - collect_files_named(&path, name, files); + // `entry.metadata()` 不跟随符号链接(不同于 `path.is_dir()`),这里据此 + // **无条件跳过一切 symlink**:目录 symlink 不递归(避免循环),文件 + // symlink 也不收集——同名文件若经 symlink 指向 sessions 根之外,会把用户 + // 意料之外的内容当作会话日志读入。代价:把 sessions 目录整体做成 symlink + // 的用户会同步不到数据,所以跳过必须留日志,便于排查"用量数据静默缺失"。 + let metadata = entry.metadata(); + if metadata.as_ref().map(|m| m.is_symlink()).unwrap_or(false) { + log::info!("[GROK-SYNC] 跳过符号链接(不跟随): {}", path.display()); + continue; + } + let is_dir = metadata.as_ref().map(|m| m.is_dir()).unwrap_or(false); + if is_dir { + collect_files_named(&path, name, files, depth + 1); } else if path.file_name().and_then(|n| n.to_str()) == Some(name) { files.push(path); } @@ -157,6 +182,16 @@ fn sync_single_grok_file(db: &Database, file_path: &Path) -> Result MAX_GROK_FILE_BYTES { + log::warn!( + "Grok session log too large ({} bytes), skipping: {}", + metadata.len(), + file_path.display() + ); + return Ok(SessionSyncResult::default()); + } + let (last_modified, _last_offset) = get_sync_state(db, &file_path_str)?; if file_modified <= last_modified { return Ok(SessionSyncResult::default()); @@ -1148,4 +1183,52 @@ mod tests { assert_eq!(Decimal::from_str(&total).expect("decimal"), expected); Ok(()) } + + #[test] + fn oversized_updates_jsonl_is_skipped_without_reading_into_memory() { + let db = Database::memory().expect("memory db"); + let temp = tempdir().expect("tempdir"); + let path = write_session_file(temp.path(), "sess-huge", &[]); + + // 制造一个超过 50 MiB 的文件,但内容为空(不会被解析)。 + let huge = std::fs::OpenOptions::new() + .write(true) + .truncate(true) + .open(&path) + .expect("open"); + huge.set_len(MAX_GROK_FILE_BYTES + 1).expect("set_len"); + drop(huge); + + let result = sync_single_grok_file(&db, &path).expect("sync should not fail"); + assert_eq!(result.imported, 0, "oversized file must not be imported"); + assert_eq!(result.skipped, 0); + assert_eq!(result.deferred_files, 0); + } + + #[test] + fn symlink_cycle_does_not_cause_stack_overflow() { + let temp = tempdir().expect("tempdir"); + let sessions = temp.path().join("sessions"); + let enc = sessions.join("enc-project"); + let sub = enc.join("sub"); + std::fs::create_dir_all(&sub).expect("create dirs"); + + // 构造循环:sub/cycle -> enc 父目录 + #[cfg(unix)] + std::os::unix::fs::symlink(&enc, sub.join("cycle")).expect("symlink"); + #[cfg(windows)] + std::os::windows::fs::symlink_dir(&enc, sub.join("cycle")).expect("symlink"); + + // 也放一个真实的目标文件,确认正常遍历仍工作 + std::fs::write(enc.join("updates.jsonl"), b"{}\n").expect("write real file"); + + let mut files = Vec::new(); + collect_files_named(&sessions, "updates.jsonl", &mut files, 0); + + assert_eq!( + files.len(), + 1, + "only the real updates.jsonl should be collected; symlink cycle must not crash" + ); + } } diff --git a/src-tauri/src/usage_script.rs b/src-tauri/src/usage_script.rs index 8c83de4f1..d636a9848 100644 --- a/src-tauri/src/usage_script.rs +++ b/src-tauri/src/usage_script.rs @@ -30,7 +30,14 @@ pub async fn execute_usage_script( } // 3. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放) - let request_config = { + // 用量脚本允许的最长执行时间(秒)。脚本来自不可信来源(deeplink、同步导入), + // 必须限制其 CPU / 内存 / 栈占用,防止一个恶意/ buggy 脚本挂死整个后端。 + const USAGE_SCRIPT_TIMEOUT_SECS: u64 = 5; + // 16 MiB 对仅构造 request 配置 / extractor 的脚本已经足够。 + const USAGE_SCRIPT_MEMORY_LIMIT_BYTES: usize = 16 * 1024 * 1024; + + /// 创建一个受控的 QuickJS Runtime:限制内存与栈,并安装执行时间中断器。 + fn create_script_runtime() -> Result { let runtime = Runtime::new().map_err(|e| { AppError::localized( "usage_script.runtime_create_failed", @@ -38,6 +45,29 @@ pub async fn execute_usage_script( format!("Failed to create JS runtime: {e}"), ) })?; + + // 内存和栈限制必须在 eval 前设置。 + runtime.set_memory_limit(USAGE_SCRIPT_MEMORY_LIMIT_BYTES); + // set_max_stack_size 默认 256 KiB 够用,这里显式重申请求它保持一致。 + runtime.set_max_stack_size(256 * 1024); + + // 时间片中断器:每轮解释器循环检查是否超时,超时则抛出不可捕获的异常。 + let deadline = std::time::Instant::now() + .checked_add(std::time::Duration::from_secs(USAGE_SCRIPT_TIMEOUT_SECS)) + .ok_or_else(|| { + AppError::localized( + "usage_script.invalid_timeout", + "无法计算脚本执行截止时间", + "Unable to compute script execution deadline", + ) + })?; + runtime.set_interrupt_handler(Some(Box::new(move || std::time::Instant::now() > deadline))); + + Ok(runtime) + } + + let request_config = { + let runtime = create_script_runtime()?; let context = Context::full(&runtime).map_err(|e| { AppError::localized( "usage_script.context_create_failed", @@ -112,13 +142,7 @@ pub async fn execute_usage_script( // 7. 在独立作用域中执行 extractor(确保 Runtime/Context 在函数结束前释放) let result: Value = { - let runtime = Runtime::new().map_err(|e| { - AppError::localized( - "usage_script.runtime_create_failed", - format!("创建 JS 运行时失败: {e}"), - format!("Failed to create JS runtime: {e}"), - ) - })?; + let runtime = create_script_runtime()?; let context = Context::full(&runtime).map_err(|e| { AppError::localized( "usage_script.context_create_failed", @@ -663,4 +687,42 @@ mod tests { } } } + + #[test] + fn infinite_loop_usage_script_is_interrupted_before_blocking_the_backend() { + // 用量脚本来自不可信输入(deeplink / 同步导入的 DB 行),必须限制 CPU 时间, + // 否则 `while(true)` 会挂死执行线程(DoS)。 + let script = r#" + (function(){ + while (true) { Math.sqrt(Math.random()); } + })(); + ({ request: { url: "https://example.com", method: "GET" } }) + "#; + + let start = std::time::Instant::now(); + let result = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .expect("tokio runtime for test") + .block_on(execute_usage_script( + script, + "sk-test", + "https://api.example.com", + 30, + None, + None, + None, + )); + let elapsed = start.elapsed(); + + assert!( + result.is_err(), + "infinite loop script must be rejected, got: {result:?}" + ); + // 必须明显短于无限等待;留足余量避免 CI 抖动,但应远小于 30 秒网络超时。 + assert!( + elapsed < std::time::Duration::from_secs(15), + "interruption took too long: {elapsed:?}" + ); + } } diff --git a/src/components/DeepLinkImportDialog.tsx b/src/components/DeepLinkImportDialog.tsx index f2d4b669c..36bc8be37 100644 --- a/src/components/DeepLinkImportDialog.tsx +++ b/src/components/DeepLinkImportDialog.tsx @@ -590,65 +590,84 @@ export function DeepLinkImportDialog() { )} - {/* Usage Script Configuration (v3.9+) */} - {request.usageScript && ( + {/* + Usage Script Configuration (v3.9+) + 区块门槛必须与后端 `build_provider_meta` 的持久化条件对齐: + 任一 usage 字段存在即落库。若只按 usageScript 开门,一条仅携带 + usageAccessToken/usageUserId 等字段的链接会在对话框毫无展示的 + 情况下把凭据写进供应商配置——展示是用户同意机制的承重部分。 + */} + {(request.usageScript || + request.usageEnabled !== undefined || + request.usageApiKey || + request.usageBaseUrl || + request.usageAccessToken || + request.usageUserId || + request.usageAutoInterval !== undefined) && (
-
-
- {t("deeplink.usageScript", { - defaultValue: "用量查询", - })} -
-
- {/* + {(request.usageScript || + request.usageEnabled !== undefined) && ( +
+
+ {t("deeplink.usageScript", { + defaultValue: "用量查询", + })} +
+
+ {/* 判据是 `=== true`,与后端 `usage_enabled.unwrap_or(false)` 严格对齐。此前用的 `!== false` 会把"链接没说"渲染成绿色的 「已启用」——徽章必须显示实际会发生的事,不能比后端更乐观。 */} - - {request.usageEnabled === true - ? t("deeplink.usageScriptEnabled", { - defaultValue: "已启用", - }) - : t("deeplink.usageScriptDisabled", { - defaultValue: "未启用", - })} - + + {request.usageEnabled === true + ? t("deeplink.usageScriptEnabled", { + defaultValue: "已启用", + }) + : t("deeplink.usageScriptDisabled", { + defaultValue: "未启用", + })} + +
-
+ )} - {/* + {request.usageScript && ( + <> + {/* 脚本正文必须完整展示。这段是会执行的 JavaScript,而 payload 常常整条藏在中间——`whitespace-pre-wrap break-all` + 可滚动容器, 不用 truncate,任何字符都不得被 CSS 藏起来。 */} -
-
- {t("deeplink.usageScriptCode")} -
-
-                          {decodeDeeplinkPayload(
-                            request.usageScript,
-                            decodeBase64Utf8,
-                          )}
-                        
-
+
+
+ {t("deeplink.usageScriptCode")} +
+
+                              {decodeDeeplinkPayload(
+                                request.usageScript,
+                                decodeBase64Utf8,
+                              )}
+                            
+
- {/* + {/* 无条件显示,不看 `usageEnabled`:代码无论启用与否都会被写入供应商 配置,用户之后在应用内一键即可开启。挂条件等于让攻击者省略参数就能 关掉这条警告。 */} -
- - {t("deeplink.usageScriptWarning")} -
+
+ + {t("deeplink.usageScriptWarning")} +
+ + )} {/* Usage API Key (if different from provider) */} {request.usageApiKey && @@ -682,6 +701,36 @@ export function DeepLinkImportDialog() {
)} + {/* Usage Access Token (if present) */} + {request.usageAccessToken && ( +
+
+ {t("deeplink.usageAccessToken", { + defaultValue: "用量访问令牌", + })} +
+
+ {request.usageAccessToken.length > 4 + ? `${request.usageAccessToken.substring(0, 4)}${"*".repeat(12)}` + : "****"} +
+
+ )} + + {/* Usage User ID (if present) */} + {request.usageUserId && ( +
+
+ {t("deeplink.usageUserId", { + defaultValue: "用量用户 ID", + })} +
+
+ {request.usageUserId} +
+
+ )} + {/* Auto Query Interval */} {request.usageAutoInterval && request.usageAutoInterval > 0 && ( diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 4bce650e2..d0742e1b5 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2512,6 +2512,8 @@ "usageScriptWarning": "This is JavaScript that runs when usage is queried, once enabled. Import it only if you trust the source.", "usageApiKey": "Usage API Key", "usageBaseUrl": "Usage Query URL", + "usageAccessToken": "Usage Access Token", + "usageUserId": "Usage User ID", "usageAutoInterval": "Auto Query", "usageAutoIntervalValue": "Every {{minutes}} minutes", "risk": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 2a1e9ed21..6695c77bc 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2512,6 +2512,8 @@ "usageScriptWarning": "これは有効化すると使用量クエリ時に実行される JavaScript です。提供元が信頼できる場合のみインポートしてください。", "usageApiKey": "使用量 API キー", "usageBaseUrl": "使用量クエリ URL", + "usageAccessToken": "使用量アクセストークン", + "usageUserId": "使用量ユーザー ID", "usageAutoInterval": "自動クエリ", "usageAutoIntervalValue": "{{minutes}} 分ごと", "risk": { diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 945e5b8be..8e7396afe 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -2513,6 +2513,8 @@ "usageScriptWarning": "這是一段 JavaScript 程式碼,啟用後會在查詢用量時執行。請確認來源可信後再匯入。", "usageApiKey": "用量 API Key", "usageBaseUrl": "用量查詢位址", + "usageAccessToken": "用量存取權杖", + "usageUserId": "用量使用者 ID", "usageAutoInterval": "自動查詢", "usageAutoIntervalValue": "每 {{minutes}} 分鐘", "risk": { diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 98e512b22..570b6779b 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -2512,6 +2512,8 @@ "usageScriptWarning": "这是一段 JavaScript 代码,启用后会在查询用量时执行。请确认来源可信后再导入。", "usageApiKey": "用量 API Key", "usageBaseUrl": "用量查询地址", + "usageAccessToken": "用量访问令牌", + "usageUserId": "用量用户 ID", "usageAutoInterval": "自动查询", "usageAutoIntervalValue": "每 {{minutes}} 分钟", "risk": { diff --git a/tests/components/DeepLinkImportDialog.test.tsx b/tests/components/DeepLinkImportDialog.test.tsx new file mode 100644 index 000000000..eeb6d676a --- /dev/null +++ b/tests/components/DeepLinkImportDialog.test.tsx @@ -0,0 +1,102 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog"; +import { emitTauriEvent } from "../msw/tauriMocks"; + +vi.mock("@/components/ui/dialog", () => ({ + Dialog: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogHeader: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogTitle: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), + DialogDescription: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), + DialogFooter: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +const Wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +describe("DeepLinkImportDialog", () => { + it("renders masked usage access token and user id for provider imports", async () => { + render(, { wrapper: Wrapper }); + + act(() => { + emitTauriEvent("deeplink-import", { + version: "v1", + resource: "provider", + app: "claude", + name: "Test Provider", + homepage: "https://example.com", + endpoint: "https://api.example.com", + apiKey: "sk-provider-key", + usageEnabled: true, + usageScript: btoa("console.log('usage');"), + usageApiKey: "sk-usage-key", + usageBaseUrl: "https://usage.example.com", + usageAccessToken: "pat-secret-token", + usageUserId: "user-12345", + usageAutoInterval: 60, + }); + }); + + await waitFor(() => { + expect(screen.getByText("用量访问令牌")).toBeInTheDocument(); + }); + + expect(screen.getByText("用量用户 ID")).toBeInTheDocument(); + expect(screen.getByText("user-12345")).toBeInTheDocument(); + // Masked: first 4 chars + 12 stars + expect(screen.getByText("pat-************")).toBeInTheDocument(); + }); + + it("shows usage credentials even when the deeplink carries no usageScript", async () => { + // 后端 build_provider_meta 在任一 usage 字段存在时即持久化(含 access_token + // 与 user_id)。若对话框只在 usageScript 存在时开门,这条链接会把凭据静默 + // 写进供应商配置。撤销门槛 widening(恢复只按 usageScript 开门)本测试即失败。 + render(, { wrapper: Wrapper }); + + act(() => { + emitTauriEvent("deeplink-import", { + version: "v1", + resource: "provider", + app: "claude", + name: "Token Only Provider", + homepage: "https://example.com", + endpoint: "https://api.example.com", + apiKey: "sk-provider-key", + usageAccessToken: "pat-secret-token", + usageUserId: "user-12345", + }); + }); + + await waitFor(() => { + expect(screen.getByText("用量访问令牌")).toBeInTheDocument(); + }); + + expect(screen.getByText("pat-************")).toBeInTheDocument(); + expect(screen.getByText("用量用户 ID")).toBeInTheDocument(); + expect(screen.getByText("user-12345")).toBeInTheDocument(); + // 没有脚本就不应渲染脚本执行警告与脚本代码区 + expect( + screen.queryByText( + "这是一段 JavaScript 代码,启用后会在查询用量时执行。请确认来源可信后再导入。", + ), + ).not.toBeInTheDocument(); + expect(screen.queryByText("脚本代码")).not.toBeInTheDocument(); + }); +});