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:
@@ -4,6 +4,14 @@ use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
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)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -41,6 +49,22 @@ pub struct CreateJournalReq {
|
||||
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)]
|
||||
pub struct UpdateJournalReq {
|
||||
@@ -52,9 +76,28 @@ pub struct UpdateJournalReq {
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub is_private: Option<bool>,
|
||||
pub shared_to_class: Option<bool>,
|
||||
#[validate(range(min = 0, message = "版本号不能为负数"))]
|
||||
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)]
|
||||
pub struct JournalResp {
|
||||
@@ -90,6 +133,16 @@ pub struct JoinClassReq {
|
||||
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)]
|
||||
pub struct UpdateClassReq {
|
||||
@@ -100,6 +153,7 @@ pub struct UpdateClassReq {
|
||||
#[validate(length(max = 100, message = "学校名称最长 100 字符"))]
|
||||
pub school_name: Option<String>,
|
||||
/// 乐观锁版本号
|
||||
#[validate(range(min = 0, message = "版本号不能为负数"))]
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
@@ -132,6 +186,32 @@ pub struct SyncReq {
|
||||
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)]
|
||||
pub enum SyncChange {
|
||||
@@ -216,6 +296,7 @@ pub struct UpdateTopicReq {
|
||||
/// 截止日期
|
||||
pub due_date: Option<chrono::NaiveDate>,
|
||||
/// 乐观锁版本号
|
||||
#[validate(range(min = 0, message = "版本号不能为负数"))]
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
@@ -229,11 +310,13 @@ pub struct CreateStickerPackReq {
|
||||
#[validate(length(max = 500, message = "描述最长 500 字符"))]
|
||||
pub description: Option<String>,
|
||||
/// 缩略图 URL
|
||||
#[validate(length(max = 500, message = "缩略图 URL 最长 500 字符"))]
|
||||
pub thumbnail_url: Option<String>,
|
||||
/// 是否免费
|
||||
#[serde(default = "default_true")]
|
||||
pub is_free: bool,
|
||||
/// 价格(积分)
|
||||
#[validate(range(min = 0, message = "价格不能为负数"))]
|
||||
#[serde(default)]
|
||||
pub price: i32,
|
||||
/// 分类
|
||||
@@ -253,10 +336,12 @@ pub struct UpdateStickerPackReq {
|
||||
#[validate(length(max = 500, message = "描述最长 500 字符"))]
|
||||
pub description: Option<String>,
|
||||
/// 缩略图 URL
|
||||
#[validate(length(max = 500, message = "缩略图 URL 最长 500 字符"))]
|
||||
pub thumbnail_url: Option<String>,
|
||||
/// 是否免费
|
||||
pub is_free: Option<bool>,
|
||||
/// 价格(积分)
|
||||
#[validate(range(min = 0, message = "价格不能为负数"))]
|
||||
pub price: Option<i32>,
|
||||
/// 分类
|
||||
#[validate(length(max = 30, message = "分类最长 30 字符"))]
|
||||
@@ -272,6 +357,8 @@ pub struct CreateStickerReq {
|
||||
/// 图片 URL
|
||||
#[validate(length(min = 1, max = 500, message = "图片 URL 长度 1-500 字符"))]
|
||||
pub image_url: String,
|
||||
/// 贴纸包 ID
|
||||
pub pack_id: Option<uuid::Uuid>,
|
||||
/// 分类
|
||||
#[validate(length(max = 30, message = "分类最长 30 字符"))]
|
||||
pub category: Option<String>,
|
||||
|
||||
@@ -85,6 +85,7 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
req.validate_code().map_err(AppError::Validation)?;
|
||||
require_permission(&ctx, "diary.journal.create")?;
|
||||
|
||||
if req.class_code.trim().is_empty() {
|
||||
|
||||
@@ -61,6 +61,7 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
req.validate_tags().map_err(AppError::Validation)?;
|
||||
require_permission(&ctx, "diary.journal.create")?;
|
||||
|
||||
// 基础验证
|
||||
@@ -149,6 +150,7 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
req.validate_tags().map_err(AppError::Validation)?;
|
||||
require_permission(&ctx, "diary.journal.update")?;
|
||||
|
||||
let resp = JournalService::update(
|
||||
@@ -165,9 +167,10 @@ where
|
||||
}
|
||||
|
||||
/// 删除日记请求体(包含版本号)
|
||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
||||
#[derive(Debug, Deserialize, Validate, utoipa::ToSchema)]
|
||||
pub struct DeleteJournalReq {
|
||||
/// 当前版本号(乐观锁)
|
||||
#[validate(range(min = 0, message = "版本号不能为负数"))]
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
@@ -202,6 +205,8 @@ where
|
||||
{
|
||||
require_permission(&ctx, "diary.journal.delete")?;
|
||||
|
||||
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
JournalService::delete(
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
|
||||
@@ -94,6 +94,8 @@ where
|
||||
{
|
||||
require_permission(&ctx, "diary.parent.bind")?;
|
||||
|
||||
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let binding = ParentService::bind_child(
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
@@ -259,6 +261,8 @@ where
|
||||
{
|
||||
require_permission(&ctx, "diary.parent.bind")?;
|
||||
|
||||
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let count = ParentService::delete_child_data(
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
@@ -301,6 +305,8 @@ where
|
||||
{
|
||||
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?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
|
||||
@@ -40,6 +40,7 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
req.validate_changes_data().map_err(AppError::Validation)?;
|
||||
require_permission(&ctx, "diary.journal.read")?;
|
||||
|
||||
let resp = SyncService::sync(
|
||||
|
||||
Reference in New Issue
Block a user