fix(diary): 系统性修复 DTO 输入验证 — 42 项审计发现中输入验证类全部修复

DTO 字段级验证:
- version 字段全部添加 range(min=0) 防止负数
- 标签内容验证: 单个标签最长 30 字符,不允许空白
- 班级码正则: 仅允许字母数字,拒绝特殊字符
- 贴纸包 price 添加 range(min=0) 防止负价格
- thumbnail_url/image_url 添加 length(max=500) 限制
- 同步请求 data payload 限制 1MB/条

Handler validate() 调用补齐:
- delete_journal: DeleteJournalReq 添加 Validate derive + handler 调用
- bind_child / unbind_child / delete_child_data: 补齐 req.validate() 调用
- join_class: 添加 validate_code() 字母数字检查
- sync_journals: 添加 validate_changes_data() payload 大小检查

审计覆盖: 5a-C01/02/03 + 5a-H02/03/04 + B-03 + 7b-C02
This commit is contained in:
iven
2026-06-07 12:55:50 +08:00
parent 3c3d70c751
commit dbb74b6545
5 changed files with 101 additions and 1 deletions

View File

@@ -4,6 +4,14 @@ use serde::{Deserialize, Serialize};
use utoipa::ToSchema; use utoipa::ToSchema;
use validator::Validate; use validator::Validate;
/// 标签字符串验证:单个标签最长 30 字符
const TAG_MAX_LEN: usize = 30;
/// 班级码正则:仅允许字母和数字
fn validate_class_code(code: &str) -> bool {
code.chars().all(|c| c.is_ascii_alphanumeric())
}
/// 日记心情枚举 /// 日记心情枚举
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
@@ -41,6 +49,22 @@ pub struct CreateJournalReq {
pub assigned_topic_id: Option<uuid::Uuid>, pub assigned_topic_id: Option<uuid::Uuid>,
} }
impl CreateJournalReq {
/// 验证标签内容:每个标签非空且不超过 30 字符
pub fn validate_tags(&self) -> Result<(), String> {
for tag in &self.tags {
let trimmed = tag.trim();
if trimmed.is_empty() {
return Err("标签不能为空".to_string());
}
if trimmed.len() > TAG_MAX_LEN {
return Err(format!("标签「{}」超过 {} 字符", trimmed, TAG_MAX_LEN));
}
}
Ok(())
}
}
/// 更新日记请求 /// 更新日记请求
#[derive(Debug, Deserialize, Validate, ToSchema)] #[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct UpdateJournalReq { pub struct UpdateJournalReq {
@@ -52,9 +76,28 @@ pub struct UpdateJournalReq {
pub tags: Option<Vec<String>>, pub tags: Option<Vec<String>>,
pub is_private: Option<bool>, pub is_private: Option<bool>,
pub shared_to_class: Option<bool>, pub shared_to_class: Option<bool>,
#[validate(range(min = 0, message = "版本号不能为负数"))]
pub version: i32, pub version: i32,
} }
impl UpdateJournalReq {
/// 验证标签内容
pub fn validate_tags(&self) -> Result<(), String> {
if let Some(ref tags) = self.tags {
for tag in tags {
let trimmed = tag.trim();
if trimmed.is_empty() {
return Err("标签不能为空".to_string());
}
if trimmed.len() > TAG_MAX_LEN {
return Err(format!("标签「{}」超过 {} 字符", trimmed, TAG_MAX_LEN));
}
}
}
Ok(())
}
}
/// 日记响应 /// 日记响应
#[derive(Debug, Serialize, ToSchema)] #[derive(Debug, Serialize, ToSchema)]
pub struct JournalResp { pub struct JournalResp {
@@ -90,6 +133,16 @@ pub struct JoinClassReq {
pub class_code: String, pub class_code: String,
} }
impl JoinClassReq {
/// 验证班级码仅含字母数字
pub fn validate_code(&self) -> Result<(), String> {
if !validate_class_code(&self.class_code) {
return Err("班级码仅允许字母和数字".to_string());
}
Ok(())
}
}
/// 更新班级请求 /// 更新班级请求
#[derive(Debug, Deserialize, Validate, ToSchema)] #[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct UpdateClassReq { pub struct UpdateClassReq {
@@ -100,6 +153,7 @@ pub struct UpdateClassReq {
#[validate(length(max = 100, message = "学校名称最长 100 字符"))] #[validate(length(max = 100, message = "学校名称最长 100 字符"))]
pub school_name: Option<String>, pub school_name: Option<String>,
/// 乐观锁版本号 /// 乐观锁版本号
#[validate(range(min = 0, message = "版本号不能为负数"))]
pub version: i32, pub version: i32,
} }
@@ -132,6 +186,32 @@ pub struct SyncReq {
pub changes: Vec<SyncChange>, pub changes: Vec<SyncChange>,
} }
/// 单条同步变更的 JSON data 最大字节数
const SYNC_DATA_MAX_BYTES: usize = 1024 * 1024; // 1 MB
impl SyncReq {
/// 验证每条 SyncChange 的 data 字段大小
pub fn validate_changes_data(&self) -> Result<(), String> {
for (i, change) in self.changes.iter().enumerate() {
match change {
SyncChange::CreateJournal { data } | SyncChange::UpdateJournal { data, .. } => {
let len = serde_json::to_string(data)
.map(|s| s.len())
.unwrap_or(SYNC_DATA_MAX_BYTES + 1);
if len > SYNC_DATA_MAX_BYTES {
return Err(format!(
"{} 条变更数据过大 ({} > {} 字节)",
i + 1, len, SYNC_DATA_MAX_BYTES
));
}
}
SyncChange::DeleteJournal { .. } => {}
}
}
Ok(())
}
}
/// 同步变更条目 /// 同步变更条目
#[derive(Debug, Serialize, Deserialize, ToSchema)] #[derive(Debug, Serialize, Deserialize, ToSchema)]
pub enum SyncChange { pub enum SyncChange {
@@ -216,6 +296,7 @@ pub struct UpdateTopicReq {
/// 截止日期 /// 截止日期
pub due_date: Option<chrono::NaiveDate>, pub due_date: Option<chrono::NaiveDate>,
/// 乐观锁版本号 /// 乐观锁版本号
#[validate(range(min = 0, message = "版本号不能为负数"))]
pub version: i32, pub version: i32,
} }
@@ -229,11 +310,13 @@ pub struct CreateStickerPackReq {
#[validate(length(max = 500, message = "描述最长 500 字符"))] #[validate(length(max = 500, message = "描述最长 500 字符"))]
pub description: Option<String>, pub description: Option<String>,
/// 缩略图 URL /// 缩略图 URL
#[validate(length(max = 500, message = "缩略图 URL 最长 500 字符"))]
pub thumbnail_url: Option<String>, pub thumbnail_url: Option<String>,
/// 是否免费 /// 是否免费
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub is_free: bool, pub is_free: bool,
/// 价格(积分) /// 价格(积分)
#[validate(range(min = 0, message = "价格不能为负数"))]
#[serde(default)] #[serde(default)]
pub price: i32, pub price: i32,
/// 分类 /// 分类
@@ -253,10 +336,12 @@ pub struct UpdateStickerPackReq {
#[validate(length(max = 500, message = "描述最长 500 字符"))] #[validate(length(max = 500, message = "描述最长 500 字符"))]
pub description: Option<String>, pub description: Option<String>,
/// 缩略图 URL /// 缩略图 URL
#[validate(length(max = 500, message = "缩略图 URL 最长 500 字符"))]
pub thumbnail_url: Option<String>, pub thumbnail_url: Option<String>,
/// 是否免费 /// 是否免费
pub is_free: Option<bool>, pub is_free: Option<bool>,
/// 价格(积分) /// 价格(积分)
#[validate(range(min = 0, message = "价格不能为负数"))]
pub price: Option<i32>, pub price: Option<i32>,
/// 分类 /// 分类
#[validate(length(max = 30, message = "分类最长 30 字符"))] #[validate(length(max = 30, message = "分类最长 30 字符"))]
@@ -272,6 +357,8 @@ pub struct CreateStickerReq {
/// 图片 URL /// 图片 URL
#[validate(length(min = 1, max = 500, message = "图片 URL 长度 1-500 字符"))] #[validate(length(min = 1, max = 500, message = "图片 URL 长度 1-500 字符"))]
pub image_url: String, pub image_url: String,
/// 贴纸包 ID
pub pack_id: Option<uuid::Uuid>,
/// 分类 /// 分类
#[validate(length(max = 30, message = "分类最长 30 字符"))] #[validate(length(max = 30, message = "分类最长 30 字符"))]
pub category: Option<String>, pub category: Option<String>,

View File

@@ -85,6 +85,7 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
req.validate().map_err(|e| AppError::Validation(e.to_string()))?; req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
req.validate_code().map_err(AppError::Validation)?;
require_permission(&ctx, "diary.journal.create")?; require_permission(&ctx, "diary.journal.create")?;
if req.class_code.trim().is_empty() { if req.class_code.trim().is_empty() {

View File

@@ -61,6 +61,7 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
req.validate().map_err(|e| AppError::Validation(e.to_string()))?; req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
req.validate_tags().map_err(AppError::Validation)?;
require_permission(&ctx, "diary.journal.create")?; require_permission(&ctx, "diary.journal.create")?;
// 基础验证 // 基础验证
@@ -149,6 +150,7 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
req.validate().map_err(|e| AppError::Validation(e.to_string()))?; req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
req.validate_tags().map_err(AppError::Validation)?;
require_permission(&ctx, "diary.journal.update")?; require_permission(&ctx, "diary.journal.update")?;
let resp = JournalService::update( let resp = JournalService::update(
@@ -165,9 +167,10 @@ where
} }
/// 删除日记请求体(包含版本号) /// 删除日记请求体(包含版本号)
#[derive(Debug, Deserialize, utoipa::ToSchema)] #[derive(Debug, Deserialize, Validate, utoipa::ToSchema)]
pub struct DeleteJournalReq { pub struct DeleteJournalReq {
/// 当前版本号(乐观锁) /// 当前版本号(乐观锁)
#[validate(range(min = 0, message = "版本号不能为负数"))]
pub version: i32, pub version: i32,
} }
@@ -202,6 +205,8 @@ where
{ {
require_permission(&ctx, "diary.journal.delete")?; require_permission(&ctx, "diary.journal.delete")?;
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
JournalService::delete( JournalService::delete(
ctx.tenant_id, ctx.tenant_id,
ctx.user_id, ctx.user_id,

View File

@@ -94,6 +94,8 @@ where
{ {
require_permission(&ctx, "diary.parent.bind")?; require_permission(&ctx, "diary.parent.bind")?;
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
let binding = ParentService::bind_child( let binding = ParentService::bind_child(
ctx.tenant_id, ctx.tenant_id,
ctx.user_id, ctx.user_id,
@@ -259,6 +261,8 @@ where
{ {
require_permission(&ctx, "diary.parent.bind")?; require_permission(&ctx, "diary.parent.bind")?;
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
let count = ParentService::delete_child_data( let count = ParentService::delete_child_data(
ctx.tenant_id, ctx.tenant_id,
ctx.user_id, ctx.user_id,
@@ -301,6 +305,8 @@ where
{ {
require_permission(&ctx, "diary.parent.bind")?; require_permission(&ctx, "diary.parent.bind")?;
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
ParentService::unbind_child(ctx.tenant_id, ctx.user_id, req.child_id, &state.db).await?; ParentService::unbind_child(ctx.tenant_id, ctx.user_id, req.child_id, &state.db).await?;
Ok(Json(ApiResponse { Ok(Json(ApiResponse {

View File

@@ -40,6 +40,7 @@ where
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
req.validate().map_err(|e| AppError::Validation(e.to_string()))?; req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
req.validate_changes_data().map_err(AppError::Validation)?;
require_permission(&ctx, "diary.journal.read")?; require_permission(&ctx, "diary.journal.read")?;
let resp = SyncService::sync( let resp = SyncService::sync(