From b053272825e2a5b08ba89abd0bd9c280006bf746 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E8=88=AA=E5=AE=87?= <3364451258@qq.com> Date: Sun, 21 Jun 2026 16:09:08 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AE=89=E5=85=A8=E6=BC=8F=E6=B4=9E?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20=E2=80=94=20CLI=E5=AF=86=E7=A0=81=E6=B3=84?= =?UTF-8?q?=E9=9C=B2=20+=20margin=E4=B8=8A=E9=99=90=20+=20=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E6=A3=80=E6=9F=A5=20+=20CSP/=E9=99=90=E9=80=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL: - CLI --password 未提供时自动从 QRGEN_WIFI_PASSWORD 环境变量读取 避免密码出现在 ps aux / /proc/pid/cmdline 进程列表中 HIGH: - unsafe from_utf8_unchecked SAFETY 注释增强, 详细解释为何有效 - CLI --margin 添加 value_parser range(0..=100), 防止 u8=255 导致内存爆炸 - Web CORS permissive 添加注释说明公开 API 设计理由 - GUI FS 权限收紧: 写权限从 /c/Users/33644/** 限制为 Downloads/Documents/Desktop/AppData MEDIUM: - 新增 QrError::InvalidEcLevel(String), 不再复用 InvalidVersion(0) - --logo/--batch/--output_dir 均添加 check_path() 路径遍历检查 - Web 添加 tower::limit::ConcurrencyLimitLayer(10) 并发限制防 CPU 耗尽 - decode_image 添加 4096x4096 图片尺寸上限防解压炸弹 LOW: - Web 添加 Content-Security-Policy 响应头 --- Cargo.lock | 1 + cli/src/main.rs | 20 +++++++++++++++++--- core/src/decoder/image.rs | 13 ++++++++++++- core/src/error.rs | 3 +++ core/src/qr.rs | 7 ++++++- core/src/version.rs | 2 +- gui/capabilities/default.json | 18 +++++++++++++++--- gui/gen/schemas/capabilities.json | 2 +- web/Cargo.toml | 1 + web/src/main.rs | 13 +++++++++++-- 10 files changed, 68 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 233df19..d740269 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3332,6 +3332,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "tower", "tower-http", ] diff --git a/cli/src/main.rs b/cli/src/main.rs index 515a165..0af7f1d 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -56,7 +56,7 @@ struct EncodeOpts { version: Option, #[arg(short = 's', long, default_value = "4", value_parser = clap::value_parser!(u8).range(1..=20))] size: u8, - #[arg(short = 'm', long, default_value = "4")] + #[arg(short = 'm', long, default_value = "4", value_parser = clap::value_parser!(u8).range(0..=100))] margin: u8, #[arg(long)] fg: Option, #[arg(long)] bg: Option, @@ -64,7 +64,7 @@ struct EncodeOpts { #[arg(short = 'f', long, default_value = "png")] format: String, #[arg(long)] mode: Option, - // WiFi + // WiFi — 密码优先从环境变量 QRGEN_WIFI_PASSWORD 读取,避免命令行泄露 #[arg(long)] ssid: Option, #[arg(long)] password: Option, #[arg(long, default_value = "WPA")] @@ -174,12 +174,20 @@ fn cmd_encode(content: &str, output: &Option, opts: &EncodeOpts) -> Resu let final_text = if let Some(m) = &opts.mode { build_mode(m, opts, &text)? } else if let Some(ref bf) = opts.batch { + check_path(bf)?; // 批量文件路径检查 + if let Some(od) = &opts.output_dir { + check_path(od)?; // 输出目录路径检查 + } return do_batch(bf, opts); } else { text }; let level: EcLevel = opts.level.parse().map_err(|e: String| anyhow::anyhow!(e))?; + // --logo 文件路径也需安全检查 + if let Some(logo_path) = &opts.logo { + check_path(logo_path)?; + } let logo = opts .logo .as_ref() @@ -252,9 +260,15 @@ fn build_mode(mode: &str, opts: &EncodeOpts, fb: &str) -> Result { .ssid .as_deref() .ok_or_else(|| anyhow::anyhow!("WiFi 模式需要 --ssid"))?; + // 密码优先从 --password 读取,未提供时尝试环境变量 QRGEN_WIFI_PASSWORD + let pwd = opts + .password + .as_deref() + .or_else(|| std::env::var("QRGEN_WIFI_PASSWORD").ok().as_deref()) + .unwrap_or(""); Ok(text_builder::build_wifi_text( s, - opts.password.as_deref().unwrap_or(""), + pwd, &opts.encryption, opts.hidden, )) diff --git a/core/src/decoder/image.rs b/core/src/decoder/image.rs index 9cb2ec3..b21ceb1 100644 --- a/core/src/decoder/image.rs +++ b/core/src/decoder/image.rs @@ -3,13 +3,24 @@ //! 提供多遍自适应二值化策略和灰度图加载 API。 pub use image::GrayImage; +use image::GenericImageView; + +/// 最大图片尺寸(宽或高),防止解压炸弹(压缩 1MB→解压 10000×10000 像素) +const MAX_IMAGE_DIMENSION: u32 = 4096; /// 从图像字节加载为灰度图(公共 API,供调用方自定义预处理) /// -/// 支持 PNG/JPEG/WebP/BMP 等常见格式。 +/// 支持 PNG/JPEG/WebP/BMP 等常见格式。限制最大尺寸 4096×4096 防止资源耗尽。 pub fn load_gray(bytes: &[u8]) -> Result { let img = image::load_from_memory(bytes) .map_err(|e| crate::error::QrError::DecodeFail(format!("图像解码失败: {e}")))?; + let (w, h) = img.dimensions(); + if w > MAX_IMAGE_DIMENSION || h > MAX_IMAGE_DIMENSION { + return Err(crate::error::QrError::DecodeFail(format!( + "图片过大 ({}×{}),最大支持 {}×{}", + w, h, MAX_IMAGE_DIMENSION, MAX_IMAGE_DIMENSION + ))); + } Ok(img.to_luma8()) } diff --git a/core/src/error.rs b/core/src/error.rs index 963213f..30917ad 100644 --- a/core/src/error.rs +++ b/core/src/error.rs @@ -11,6 +11,8 @@ pub enum QrError { EmptyInput, /// 无效版本号 (1-40) InvalidVersion(u8), + /// 无效纠错级别 + InvalidEcLevel(String), /// 数据过长,超出 QR 码最大容量 DataTooLong, /// 解码失败 @@ -30,6 +32,7 @@ impl fmt::Display for QrError { match self { QrError::EmptyInput => write!(f, "输入为空"), QrError::InvalidVersion(v) => write!(f, "无效版本号 (1-40): {v}"), + QrError::InvalidEcLevel(s) => write!(f, "无效纠错级别: '{s}',支持 L/M/Q/H"), QrError::DataTooLong => write!(f, "数据过长,超出 QR 码最大容量"), QrError::DecodeFail(msg) => write!(f, "解码失败: {msg}"), QrError::FormatCorrupted(msg) => write!(f, "格式信息损坏: {msg}"), diff --git a/core/src/qr.rs b/core/src/qr.rs index 059c488..6d64d11 100644 --- a/core/src/qr.rs +++ b/core/src/qr.rs @@ -324,7 +324,12 @@ pub(crate) fn parse_hex_color(s: &str) -> Result<[u8; 3], QrError> { match hex.len() { 3 => { // #RGB → 每个分量乘以 17 扩展为 #RRGGBB(避免 String 分配) - // SAFETY: hex 来自 `s[1..]`,已通过 starts_with('#') 检查,且只含 ASCII hex 字符 + // SAFETY: hex 来自 `s[1..]`,s 本身是 &str(由函数参数保证), + // 因此 s 的每个字节都是有效 UTF-8。bytes[0]/[1]/[2] 取自 hex, + // 是有效 UTF-8 单字节 ASCII hex 字符。将同一有效 ASCII 字节 + // 重复两次构成长度为 2 的字节序列,该序列必然也是有效 UTF-8。 + // 这是因为:ASCII 单字节字符 (0x00-0x7F) 重复任意次,每个字节 + // 都独立是有效的 UTF-8 序列,不依赖相邻字节。 let bytes = hex.as_bytes(); let r = u8::from_str_radix(unsafe { std::str::from_utf8_unchecked(&[bytes[0], bytes[0]]) diff --git a/core/src/version.rs b/core/src/version.rs index 987b6d4..eae2ca5 100644 --- a/core/src/version.rs +++ b/core/src/version.rs @@ -49,7 +49,7 @@ impl FromStr for EcLevel { "M" => Ok(EcLevel::M), "Q" => Ok(EcLevel::Q), "H" => Ok(EcLevel::H), - _ => Err(QrError::InvalidVersion(0)), // 复用变体 + _ => Err(QrError::InvalidEcLevel(s.to_string())), } } } diff --git a/gui/capabilities/default.json b/gui/capabilities/default.json index e9b1e11..51f4b0a 100644 --- a/gui/capabilities/default.json +++ b/gui/capabilities/default.json @@ -1,7 +1,7 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "QRGen 默认权限", + "description": "QRGen 默认权限 — 最小权限原则", "windows": ["main"], "permissions": [ "core:default", @@ -10,11 +10,23 @@ "clipboard-manager:default", { "identifier": "fs:allow-write-file", - "allow": [{ "path": "$HOME/**" }] + "allow": [ + { "path": "$HOME/Downloads/**" }, + { "path": "$HOME/Documents/**" }, + { "path": "$HOME/Desktop/**" }, + { "path": "$APPDATA/**" } + ] }, { "identifier": "fs:allow-read-file", - "allow": [{ "path": "$HOME/**" }, { "path": "$TEMP/**" }] + "allow": [ + { "path": "$HOME/Downloads/**" }, + { "path": "$HOME/Documents/**" }, + { "path": "$HOME/Desktop/**" }, + { "path": "$HOME/Pictures/**" }, + { "path": "$APPDATA/**" }, + { "path": "$TEMP/**" } + ] } ] } diff --git a/gui/gen/schemas/capabilities.json b/gui/gen/schemas/capabilities.json index e0b158f..1af2d7c 100644 --- a/gui/gen/schemas/capabilities.json +++ b/gui/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"QRGen 默认权限","local":true,"windows":["main"],"permissions":["core:default","store:default","dialog:default","clipboard-manager:default",{"identifier":"fs:allow-write-file","allow":[{"path":"$HOME/**"}]},{"identifier":"fs:allow-read-file","allow":[{"path":"$HOME/**"},{"path":"$TEMP/**"}]}]}} \ No newline at end of file +{"default":{"identifier":"default","description":"QRGen 默认权限 — 最小权限原则","local":true,"windows":["main"],"permissions":["core:default","store:default","dialog:default","clipboard-manager:default",{"identifier":"fs:allow-write-file","allow":[{"path":"$HOME/Downloads/**"},{"path":"$HOME/Documents/**"},{"path":"$HOME/Desktop/**"},{"path":"$APPDATA/**"}]},{"identifier":"fs:allow-read-file","allow":[{"path":"$HOME/Downloads/**"},{"path":"$HOME/Documents/**"},{"path":"$HOME/Desktop/**"},{"path":"$HOME/Pictures/**"},{"path":"$APPDATA/**"},{"path":"$TEMP/**"}]}]}} \ No newline at end of file diff --git a/web/Cargo.toml b/web/Cargo.toml index ea0bfe9..7a13ed9 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -12,3 +12,4 @@ tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tower-http = { version = "0.6", features = ["cors", "limit", "set-header"] } +tower = "0.5" diff --git a/web/src/main.rs b/web/src/main.rs index ef488cf..413652d 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -174,7 +174,8 @@ async fn main() { .route("/api/qr", get(generate_qr_get).post(generate_qr_post)) .route("/api/decode", post(decode_qr)) // 安全层 - .layer(RequestBodyLimitLayer::new(10 * 1024 * 1024)) // 10 MB + .layer(RequestBodyLimitLayer::new(10 * 1024 * 1024)) // 10 MB 请求体限制 + // CORS: 公开 API,允许任意来源调用(无 cookie/session,CSRF 不适用) .layer(CorsLayer::permissive()) .layer(SetResponseHeaderLayer::if_not_present( header::X_CONTENT_TYPE_OPTIONS, @@ -187,7 +188,15 @@ async fn main() { .layer(SetResponseHeaderLayer::if_not_present( header::REFERRER_POLICY, header::HeaderValue::from_static("strict-origin-when-cross-origin"), - )); + )) + .layer(SetResponseHeaderLayer::if_not_present( + header::CONTENT_SECURITY_POLICY, + header::HeaderValue::from_static( + "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data: blob:", + ), + )) + // 并发限制: 最多 10 个同时编码/解码请求,防止 CPU 耗尽 + .layer(tower::limit::ConcurrencyLimitLayer::new(10)); let addr = "0.0.0.0:3000"; println!("QRGen Web → http://{}", addr);