fix(security): cap scripts, file reads, and proxy bodies; surface deeplink usage fields (#5919)

* fix(security): harden usage script execution and Grok session log collection

Three hardening fixes in v3.19.0 code:

1. **usage_script: cap JS runtime resources and execution time**
   `execute_usage_script` used a vanilla `Runtime::new()` with no limits,
   so a malicious or buggy script delivered via deeplink or synced DB could
   hang the backend thread forever with `while(true)`. Add
   `create_script_runtime()` enforcing a 5-second interrupt handler, 16 MiB
   memory limit, and 256 KiB stack limit. Regression test confirms an
   infinite-loop script is rejected within seconds instead of blocking.

2. **Grok session log: skip oversized files**
   `sync_single_grok_file` loaded the entire `updates.jsonl` into memory
   with no size cap. Add a 50 MiB limit and skip files that exceed it.

3. **Grok session log: bound directory traversal**
   `collect_files_named` recursed without depth limits and followed
   symlinks, so a symlink cycle under `~/.grok/sessions` caused a stack
   overflow. Add a 16-level depth limit and skip symlinks entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): show hidden usage access token and user id in deeplink provider import dialog

The provider import confirmation dialog parsed usageAccessToken and
usageUserId from ccswitch:// URLs and persisted them in
ProviderMeta.usage_script, but never rendered them. Users could not see
these credentials before approving the import.

- Add i18n keys for usageAccessToken/usageUserId in all locales.
- Render both fields in the usage script section; mask the access token
  like usageApiKey.
- Add a regression test verifying the dialog displays the masked token
  and the user id.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(security): bound Codex model catalog and proxy response bodies

Two issues were found while auditing security boundaries:

1. **Codex model_catalog_json arbitrary file read**
    only checked the filename and
   accepted any absolute path. A malicious  could
   point CC Switch at an external file (e.g. ) or a huge
   file to exhaust memory.

   - Require the resolved path to stay inside
     using a new  helper (lexical, no filesystem calls).
   - Treat Unix-style absolute paths as absolute on Windows.
   - Cap catalog reads at 32 MiB.

2. **Unbounded proxy response bodies / compression bombs**
   Non-streaming responses and error responses were read with
    and fully decompressed without any byte
   limit, allowing a malicious upstream to OOM the backend.

   - Add  with a 128 MiB ceiling.
   - Reject oversized bodies and oversized decompressed payloads with
     .
   - Map the new error variant into the Codex proxy error response.

Regression tests cover: absolute-path containment, traversal rejection,
oversized catalog file, oversized buffered/streamed proxy responses.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(ci): set up pnpm via Corepack instead of pnpm/action-setup v6

pnpm/action-setup v6 installs pnpm through a two-step self-installer:
it bootstraps the latest pnpm (11.7.0) via npm, then downloads the
pinned version (10.12.3) from the npm registry to switch. When that
second tarball fetch failed (error 23), the self-installer still
reported "done" and crashed with ENOENT on @pnpm/linux-x64's
package.json, failing the CI setup step.

Replace the action with Node 20's bundled Corepack and pin the version
once via package.json's packageManager field, so setup performs a
single exact-version download with no floating bootstrap. This also
lets release.yml drop its Windows ARM64 special case, since Corepack
has no arch restrictions, and pins the same version for local dev.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): close review gaps in proxy body caps, deeplink dialog, catalog path

Address maintainer review on the security PR:

- Proxy: bound the four remaining unbounded forwarder read paths
  (non-2xx error body, failover pre-read, both 2xx error-envelope
  validators) with bytes_with_limit + bounded decompression.
- Proxy: bytes_with_limit now accumulates Hyper/Reqwest/Streamed bodies
  chunk-by-chunk and aborts mid-stream instead of collecting the full
  body before comparing; Buffered keeps the post-hoc check.
- Proxy: decompression is budgeted at the decoder (Read::take on
  gzip/deflate/zstd, bounded brotli reader) via decompress_body_with_limit,
  so bombs are cut off at the byte budget instead of after full expansion,
  including stacked-encoding intermediates.
- Deeplink: show the usage section whenever any usage field is present,
  matching build_provider_meta persistence — a deeplink carrying only
  usageAccessToken/usageUserId no longer imports credentials invisibly.
- Codex catalog: after the lexical containment check, canonicalize the
  existing file and re-verify containment so symlinks inside ~/.codex
  cannot escape the config dir; the path_is_within doc comment no longer
  oversells the lexical check as symlink-safe.
- Grok sync: comment now matches the skip-all-symlinks behavior and each
  skip is logged, so sessions behind a symlink are diagnosable.
- Docs: CONTRIBUTING notes the packageManager pnpm pin and Corepack flow.

Regression tests follow the same methodology as round 1: reverting a
guard fails the corresponding test (server write-count assertions prove
the mid-stream abort; a truncated gzip stream distinguishes TooLarge
from read-to-end errors; symlink escape is rejected only after the
canonicalize re-check).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zayokaandClaude Fable 5 authored and GitHub committed 2026-08-04 15:44:08 +08:00
1 parent 9f19d8fdf6
commit 6b8f36431b
20 files changed
+1056 -178

No files matched your search

+6 -4
View File
@@ -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
+3 -11
View File
@@ -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
+14
View File
@@ -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
+1
View File
@@ -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",
+185 -34
View File
@@ -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<Option<Value>, 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<Option<Value>,
))
}
/// 安全地读取文件为字符串,并在超过字节上限时返回错误。
pub(crate) fn read_limited_string(path: &Path, max_bytes: u64) -> Result<String, AppError> {
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<String, AppError> {
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<PathBuf> {
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"
);
}
}
+20
View File
@@ -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<PathBuf> {
use std::path::Prefix;
+201 -23
View File
@@ -27,55 +27,114 @@ fn is_single_supported(coding: &str) -> bool {
)
}
/// 解压单个 content-coding。未知编码返回 `Ok(None)`。
fn decompress_single(coding: &str, body: &[u8]) -> Result<Option<Vec<u8>>, 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<std::io::Error> for DecompressError {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
impl From<DecompressError> 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<R: Read>(
reader: R,
max_bytes: usize,
) -> Result<Vec<u8>, 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<Option<Vec<u8>>, 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<Option<Vec<u8>>, std::io::Error> {
max_output_bytes: usize,
) -> Result<Option<Vec<u8>>, 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<Vec<u8>> = 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<Option<Vec<u8>>, 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<u8> {
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<u8> {
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 [
+6
View File
@@ -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!(),
};
+44 -28
View File
@@ -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")
);
}
+11 -5
View File
@@ -81,10 +81,10 @@ pub async fn get_status(State(state): State<ProxyState>) -> Result<Json<ProxySta
/// cc-switch–owned `model_catalog_json`, using the same path ownership rules as
/// Codex live-setting import.
pub async fn handle_models() -> Result<Json<Value>, 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<Json<Value>, 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",
}
}
+171 -12
View File
@@ -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<Bytes, ProxyError> {
/// 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<Bytes, ProxyError> {
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::<Result<Bytes, std::io::Error>>(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<std::sync::atomic::AtomicUsize>) {
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<std::sync::atomic::AtomicUsize>,
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<Incoming>:手工建立 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::<Bytes>::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;
}
}
+35 -6
View File
@@ -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!(
@@ -129,20 +129,45 @@ pub fn sync_grokbuild_usage(db: &Database) -> Result<SessionSyncResult, AppError
fn collect_grok_updates_files() -> Vec<PathBuf> {
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<PathBuf>) {
fn collect_files_named(root: &Path, name: &str, files: &mut Vec<PathBuf>, 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<SessionSyncR
.map_err(|e| AppError::Config(format!("无法读取文件元数据: {e}")))?;
let file_modified = metadata_modified_nanos(&metadata);
// 异常大文件直接跳过,避免一次性读取耗尽内存。
if metadata.len() > 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"
);
}
}
+70 -8
View File
@@ -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<Runtime, AppError> {
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:?}"
);
}
}
+92 -43
View File
@@ -590,65 +590,84 @@ export function DeepLinkImportDialog() {
</div>
)}
{/* 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) && (
<div className="space-y-3 pt-2 border-t border-border-default">
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.usageScript", {
defaultValue: "用量查询",
})}
</div>
<div className="col-span-2 text-sm">
{/*
{(request.usageScript ||
request.usageEnabled !== undefined) && (
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.usageScript", {
defaultValue: "用量查询",
})}
</div>
<div className="col-span-2 text-sm">
{/*
判据是 `=== true`,与后端 `usage_enabled.unwrap_or(false)`
严格对齐。此前用的 `!== false` 会把"链接没说"渲染成绿色的
「已启用」——徽章必须显示实际会发生的事,不能比后端更乐观。
*/}
<span
className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium ${
request.usageEnabled === true
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
}`}
>
{request.usageEnabled === true
? t("deeplink.usageScriptEnabled", {
defaultValue: "已启用",
})
: t("deeplink.usageScriptDisabled", {
defaultValue: "未启用",
})}
</span>
<span
className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium ${
request.usageEnabled === true
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
}`}
>
{request.usageEnabled === true
? t("deeplink.usageScriptEnabled", {
defaultValue: "已启用",
})
: t("deeplink.usageScriptDisabled", {
defaultValue: "未启用",
})}
</span>
</div>
</div>
</div>
)}
{/*
{request.usageScript && (
<>
{/*
脚本正文必须完整展示。这段是会执行的 JavaScript,而 payload
常常整条藏在中间——`whitespace-pre-wrap break-all` + 可滚动容器,
不用 truncate,任何字符都不得被 CSS 藏起来。
*/}
<div className="space-y-1">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.usageScriptCode")}
</div>
<pre className="max-h-48 overflow-auto rounded border border-border-default bg-muted/40 p-2 text-xs font-mono whitespace-pre-wrap break-all">
{decodeDeeplinkPayload(
request.usageScript,
decodeBase64Utf8,
)}
</pre>
</div>
<div className="space-y-1">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.usageScriptCode")}
</div>
<pre className="max-h-48 overflow-auto rounded border border-border-default bg-muted/40 p-2 text-xs font-mono whitespace-pre-wrap break-all">
{decodeDeeplinkPayload(
request.usageScript,
decodeBase64Utf8,
)}
</pre>
</div>
{/*
{/*
无条件显示,不看 `usageEnabled`:代码无论启用与否都会被写入供应商
配置,用户之后在应用内一键即可开启。挂条件等于让攻击者省略参数就能
关掉这条警告。
*/}
<div className="text-yellow-600 dark:text-yellow-500 text-sm flex items-start gap-2">
<span aria-hidden="true">⚠️</span>
<span>{t("deeplink.usageScriptWarning")}</span>
</div>
<div className="text-yellow-600 dark:text-yellow-500 text-sm flex items-start gap-2">
<span aria-hidden="true">⚠️</span>
<span>{t("deeplink.usageScriptWarning")}</span>
</div>
</>
)}
{/* Usage API Key (if different from provider) */}
{request.usageApiKey &&
@@ -682,6 +701,36 @@ export function DeepLinkImportDialog() {
</div>
)}
{/* Usage Access Token (if present) */}
{request.usageAccessToken && (
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.usageAccessToken", {
defaultValue: "用量访问令牌",
})}
</div>
<div className="col-span-2 text-sm font-mono text-muted-foreground">
{request.usageAccessToken.length > 4
? `${request.usageAccessToken.substring(0, 4)}${"*".repeat(12)}`
: "****"}
</div>
</div>
)}
{/* Usage User ID (if present) */}
{request.usageUserId && (
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.usageUserId", {
defaultValue: "用量用户 ID",
})}
</div>
<div className="col-span-2 text-sm font-mono break-all">
{request.usageUserId}
</div>
</div>
)}
{/* Auto Query Interval */}
{request.usageAutoInterval &&
request.usageAutoInterval > 0 && (
+2
View File
@@ -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": {
+2
View File
@@ -2512,6 +2512,8 @@
"usageScriptWarning": "これは有効化すると使用量クエリ時に実行される JavaScript です。提供元が信頼できる場合のみインポートしてください。",
"usageApiKey": "使用量 API キー",
"usageBaseUrl": "使用量クエリ URL",
"usageAccessToken": "使用量アクセストークン",
"usageUserId": "使用量ユーザー ID",
"usageAutoInterval": "自動クエリ",
"usageAutoIntervalValue": "{{minutes}} 分ごと",
"risk": {
+2
View File
@@ -2513,6 +2513,8 @@
"usageScriptWarning": "這是一段 JavaScript 程式碼,啟用後會在查詢用量時執行。請確認來源可信後再匯入。",
"usageApiKey": "用量 API Key",
"usageBaseUrl": "用量查詢位址",
"usageAccessToken": "用量存取權杖",
"usageUserId": "用量使用者 ID",
"usageAutoInterval": "自動查詢",
"usageAutoIntervalValue": "每 {{minutes}} 分鐘",
"risk": {
+2
View File
@@ -2512,6 +2512,8 @@
"usageScriptWarning": "这是一段 JavaScript 代码,启用后会在查询用量时执行。请确认来源可信后再导入。",
"usageApiKey": "用量 API Key",
"usageBaseUrl": "用量查询地址",
"usageAccessToken": "用量访问令牌",
"usageUserId": "用量用户 ID",
"usageAutoInterval": "自动查询",
"usageAutoIntervalValue": "每 {{minutes}} 分钟",
"risk": {
@@ -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 }) => (
<div>{children}</div>
),
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h1>{children}</h1>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p>{children}</p>
),
DialogFooter: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
}));
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
);
describe("DeepLinkImportDialog", () => {
it("renders masked usage access token and user id for provider imports", async () => {
render(<DeepLinkImportDialog />, { 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(<DeepLinkImportDialog />, { 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();
});
});