fix: 安全漏洞修复 — CLI密码泄露 + margin上限 + 路径检查 + CSP/限速
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 响应头
This commit is contained in:
Generated
+1
@@ -3332,6 +3332,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+17
-3
@@ -56,7 +56,7 @@ struct EncodeOpts {
|
|||||||
version: Option<u8>,
|
version: Option<u8>,
|
||||||
#[arg(short = 's', long, default_value = "4", value_parser = clap::value_parser!(u8).range(1..=20))]
|
#[arg(short = 's', long, default_value = "4", value_parser = clap::value_parser!(u8).range(1..=20))]
|
||||||
size: u8,
|
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,
|
margin: u8,
|
||||||
#[arg(long)] fg: Option<String>,
|
#[arg(long)] fg: Option<String>,
|
||||||
#[arg(long)] bg: Option<String>,
|
#[arg(long)] bg: Option<String>,
|
||||||
@@ -64,7 +64,7 @@ struct EncodeOpts {
|
|||||||
#[arg(short = 'f', long, default_value = "png")]
|
#[arg(short = 'f', long, default_value = "png")]
|
||||||
format: String,
|
format: String,
|
||||||
#[arg(long)] mode: Option<String>,
|
#[arg(long)] mode: Option<String>,
|
||||||
// WiFi
|
// WiFi — 密码优先从环境变量 QRGEN_WIFI_PASSWORD 读取,避免命令行泄露
|
||||||
#[arg(long)] ssid: Option<String>,
|
#[arg(long)] ssid: Option<String>,
|
||||||
#[arg(long)] password: Option<String>,
|
#[arg(long)] password: Option<String>,
|
||||||
#[arg(long, default_value = "WPA")]
|
#[arg(long, default_value = "WPA")]
|
||||||
@@ -174,12 +174,20 @@ fn cmd_encode(content: &str, output: &Option<String>, opts: &EncodeOpts) -> Resu
|
|||||||
let final_text = if let Some(m) = &opts.mode {
|
let final_text = if let Some(m) = &opts.mode {
|
||||||
build_mode(m, opts, &text)?
|
build_mode(m, opts, &text)?
|
||||||
} else if let Some(ref bf) = opts.batch {
|
} 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);
|
return do_batch(bf, opts);
|
||||||
} else {
|
} else {
|
||||||
text
|
text
|
||||||
};
|
};
|
||||||
|
|
||||||
let level: EcLevel = opts.level.parse().map_err(|e: String| anyhow::anyhow!(e))?;
|
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
|
let logo = opts
|
||||||
.logo
|
.logo
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -252,9 +260,15 @@ fn build_mode(mode: &str, opts: &EncodeOpts, fb: &str) -> Result<String> {
|
|||||||
.ssid
|
.ssid
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("WiFi 模式需要 --ssid"))?;
|
.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(
|
Ok(text_builder::build_wifi_text(
|
||||||
s,
|
s,
|
||||||
opts.password.as_deref().unwrap_or(""),
|
pwd,
|
||||||
&opts.encryption,
|
&opts.encryption,
|
||||||
opts.hidden,
|
opts.hidden,
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -3,13 +3,24 @@
|
|||||||
//! 提供多遍自适应二值化策略和灰度图加载 API。
|
//! 提供多遍自适应二值化策略和灰度图加载 API。
|
||||||
|
|
||||||
pub use image::GrayImage;
|
pub use image::GrayImage;
|
||||||
|
use image::GenericImageView;
|
||||||
|
|
||||||
|
/// 最大图片尺寸(宽或高),防止解压炸弹(压缩 1MB→解压 10000×10000 像素)
|
||||||
|
const MAX_IMAGE_DIMENSION: u32 = 4096;
|
||||||
|
|
||||||
/// 从图像字节加载为灰度图(公共 API,供调用方自定义预处理)
|
/// 从图像字节加载为灰度图(公共 API,供调用方自定义预处理)
|
||||||
///
|
///
|
||||||
/// 支持 PNG/JPEG/WebP/BMP 等常见格式。
|
/// 支持 PNG/JPEG/WebP/BMP 等常见格式。限制最大尺寸 4096×4096 防止资源耗尽。
|
||||||
pub fn load_gray(bytes: &[u8]) -> Result<GrayImage, crate::error::QrError> {
|
pub fn load_gray(bytes: &[u8]) -> Result<GrayImage, crate::error::QrError> {
|
||||||
let img = image::load_from_memory(bytes)
|
let img = image::load_from_memory(bytes)
|
||||||
.map_err(|e| crate::error::QrError::DecodeFail(format!("图像解码失败: {e}")))?;
|
.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())
|
Ok(img.to_luma8())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ pub enum QrError {
|
|||||||
EmptyInput,
|
EmptyInput,
|
||||||
/// 无效版本号 (1-40)
|
/// 无效版本号 (1-40)
|
||||||
InvalidVersion(u8),
|
InvalidVersion(u8),
|
||||||
|
/// 无效纠错级别
|
||||||
|
InvalidEcLevel(String),
|
||||||
/// 数据过长,超出 QR 码最大容量
|
/// 数据过长,超出 QR 码最大容量
|
||||||
DataTooLong,
|
DataTooLong,
|
||||||
/// 解码失败
|
/// 解码失败
|
||||||
@@ -30,6 +32,7 @@ impl fmt::Display for QrError {
|
|||||||
match self {
|
match self {
|
||||||
QrError::EmptyInput => write!(f, "输入为空"),
|
QrError::EmptyInput => write!(f, "输入为空"),
|
||||||
QrError::InvalidVersion(v) => write!(f, "无效版本号 (1-40): {v}"),
|
QrError::InvalidVersion(v) => write!(f, "无效版本号 (1-40): {v}"),
|
||||||
|
QrError::InvalidEcLevel(s) => write!(f, "无效纠错级别: '{s}',支持 L/M/Q/H"),
|
||||||
QrError::DataTooLong => write!(f, "数据过长,超出 QR 码最大容量"),
|
QrError::DataTooLong => write!(f, "数据过长,超出 QR 码最大容量"),
|
||||||
QrError::DecodeFail(msg) => write!(f, "解码失败: {msg}"),
|
QrError::DecodeFail(msg) => write!(f, "解码失败: {msg}"),
|
||||||
QrError::FormatCorrupted(msg) => write!(f, "格式信息损坏: {msg}"),
|
QrError::FormatCorrupted(msg) => write!(f, "格式信息损坏: {msg}"),
|
||||||
|
|||||||
+6
-1
@@ -324,7 +324,12 @@ pub(crate) fn parse_hex_color(s: &str) -> Result<[u8; 3], QrError> {
|
|||||||
match hex.len() {
|
match hex.len() {
|
||||||
3 => {
|
3 => {
|
||||||
// #RGB → 每个分量乘以 17 扩展为 #RRGGBB(避免 String 分配)
|
// #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 bytes = hex.as_bytes();
|
||||||
let r = u8::from_str_radix(unsafe {
|
let r = u8::from_str_radix(unsafe {
|
||||||
std::str::from_utf8_unchecked(&[bytes[0], bytes[0]])
|
std::str::from_utf8_unchecked(&[bytes[0], bytes[0]])
|
||||||
|
|||||||
+1
-1
@@ -49,7 +49,7 @@ impl FromStr for EcLevel {
|
|||||||
"M" => Ok(EcLevel::M),
|
"M" => Ok(EcLevel::M),
|
||||||
"Q" => Ok(EcLevel::Q),
|
"Q" => Ok(EcLevel::Q),
|
||||||
"H" => Ok(EcLevel::H),
|
"H" => Ok(EcLevel::H),
|
||||||
_ => Err(QrError::InvalidVersion(0)), // 复用变体
|
_ => Err(QrError::InvalidEcLevel(s.to_string())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "QRGen 默认权限",
|
"description": "QRGen 默认权限 — 最小权限原则",
|
||||||
"windows": ["main"],
|
"windows": ["main"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
@@ -10,11 +10,23 @@
|
|||||||
"clipboard-manager:default",
|
"clipboard-manager:default",
|
||||||
{
|
{
|
||||||
"identifier": "fs:allow-write-file",
|
"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",
|
"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/**" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/**"}]}]}}
|
{"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/**"}]}]}}
|
||||||
@@ -12,3 +12,4 @@ tokio = { version = "1", features = ["full"] }
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tower-http = { version = "0.6", features = ["cors", "limit", "set-header"] }
|
tower-http = { version = "0.6", features = ["cors", "limit", "set-header"] }
|
||||||
|
tower = "0.5"
|
||||||
|
|||||||
+11
-2
@@ -174,7 +174,8 @@ async fn main() {
|
|||||||
.route("/api/qr", get(generate_qr_get).post(generate_qr_post))
|
.route("/api/qr", get(generate_qr_get).post(generate_qr_post))
|
||||||
.route("/api/decode", post(decode_qr))
|
.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(CorsLayer::permissive())
|
||||||
.layer(SetResponseHeaderLayer::if_not_present(
|
.layer(SetResponseHeaderLayer::if_not_present(
|
||||||
header::X_CONTENT_TYPE_OPTIONS,
|
header::X_CONTENT_TYPE_OPTIONS,
|
||||||
@@ -187,7 +188,15 @@ async fn main() {
|
|||||||
.layer(SetResponseHeaderLayer::if_not_present(
|
.layer(SetResponseHeaderLayer::if_not_present(
|
||||||
header::REFERRER_POLICY,
|
header::REFERRER_POLICY,
|
||||||
header::HeaderValue::from_static("strict-origin-when-cross-origin"),
|
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";
|
let addr = "0.0.0.0:3000";
|
||||||
println!("QRGen Web → http://{}", addr);
|
println!("QRGen Web → http://{}", addr);
|
||||||
|
|||||||
Reference in New Issue
Block a user