diff --git a/src/api/markdown.rs b/src/api/markdown.rs index 83d7a67..b26320a 100644 --- a/src/api/markdown.rs +++ b/src/api/markdown.rs @@ -12,6 +12,16 @@ pub fn clean_html(input: &str) -> String { crate::api::sanitizer::clean_html(input) } +#[cfg(feature = "server")] +/// 将标题纯文本转义,用于安全地拼进 TOC 的 `aria-label="..."` 与 `` 正文。 +/// +/// 复用 `hooks::comment_storage::escape_html`(转义 `& < > " '`),避免在仓库内 +/// 维护第二份转义实现。原先用 `clean_html` 处理属性上下文会漏掉 `"`,标题形如 +/// `" onmouseover="alert(1)` 会越出属性边界。 +fn escape_heading_text(s: &str) -> String { + crate::hooks::comment_storage::escape_html(s) +} + #[derive(Debug, Clone)] #[cfg(feature = "server")] /// Markdown 渲染结果。 @@ -222,10 +232,13 @@ fn generate_toc_html(headings: &[(u8, String, String)]) -> String { } } - let clean_text = clean_html(text); + // 标题 text 是 pulldown-cmark 收集的纯文本(Text/Code 字面字符),不是 HTML 片段, + // 因此正文与属性两处都走 escape_heading_text(转义 & < > " ')。原先用 clean_html + // 处理属性上下文会漏掉 `"`,标题中的双引号会越出 aria-label 边界。 + let escaped_text = escape_heading_text(text); html.push_str(&format!( "
  • {}", - id, clean_text, clean_text + id, escaped_text, escaped_text )); } @@ -357,6 +370,54 @@ mod tests { assert_eq!(ul_count, 2); } + #[test] + fn generate_toc_html_escapes_quote_in_attr() { + // 标题中的双引号不得越出 aria-label 属性边界。 + let headings = vec![( + 2u8, + "\" onmouseover=\"alert(1)".to_string(), + "heading".to_string(), + )]; + let html = generate_toc_html(&headings); + // aria-label 中的双引号被转义为 ",无法越出属性边界注入新属性。 + assert!( + html.contains("aria-label=\"" onmouseover="alert(1)\""), + "aria-label 应转义内部双引号,got: {html}" + ); + // 关键:不得出现「未被引号包裹、可被解析为真实属性」的 onmouseover= 片段。 + // 正文中作为纯文本出现 "onmouseover" 字符串是安全的(无 < 或属性结构)。 + let attr_injection = "\" onmouseover=\""; + let injected = html.matches(attr_injection).count(); + // 原始输入里有 1 个裸双引号起头;转义后该模式不应再作为属性边界出现。 + // 注意 aria-label 内部的双引号已变成 ",因此裸的 `" onmouseover="` 不应存在。 + assert_eq!( + injected, 0, + "不应存在未转义的属性边界 `\" onmouseover=\"`,got: {html}" + ); + } + + #[test] + fn generate_toc_html_escapes_ampersand_in_attr() { + let headings = vec![(2u8, "A & B".to_string(), "heading".to_string())]; + let html = generate_toc_html(&headings); + assert!( + html.contains("aria-label=\"A & B\""), + "& 应在属性中转义,got: {html}" + ); + } + + #[test] + fn generate_toc_html_escapes_less_than_in_attr() { + // `<` 在属性与正文中都应被转义,避免被误解析为标签起始。 + let headings = vec![(2u8, "a < b".to_string(), "heading".to_string())]; + let html = generate_toc_html(&headings); + assert!( + html.contains("aria-label=\"a < b\""), + "< 应在属性中转义,got: {html}" + ); + assert!(!html.contains("a < b")); + } + #[test] fn render_markdown_simple_paragraph() { let result = render_markdown_enhanced("Hello **world**"); diff --git a/src/api/posts/list.rs b/src/api/posts/list.rs index 73b4260..a7e740d 100644 --- a/src/api/posts/list.rs +++ b/src/api/posts/list.rs @@ -14,6 +14,29 @@ use super::types::PostListResponse; use crate::api::error::AppError; use crate::db::pool::get_conn; +/// 单页允许的最大文章数。 +/// +/// 公开的 `list_published_posts` 接口无需认证,若不对 `per_page` 设上限, +/// 攻击者可传入巨大值迫使数据库扫描并实例化超大 Vec,造成内存放大与拒绝服务。 +const MAX_PER_PAGE: i32 = 50; + +/// 允许的最大页码。 +/// +/// `page` 无上限时,攻击者可用海量不同 `page` 值撑大缓存键空间(缓存污染), +/// 并触发无意义的超大 `OFFSET` 扫描。10_000 对任何实际博客都足够宽裕 +/// (配合 `MAX_PER_PAGE` 最多覆盖 50 万篇文章),同时把缓存键空间限制在有限范围。 +const MAX_PAGE: i32 = 10_000; + +/// 将分页参数钳制到安全范围:页码 1–`MAX_PAGE`,每页 1–`MAX_PER_PAGE`。 +/// +/// 注意:返回值必须同时用于缓存键与 SQL 查询,避免同一逻辑页落入不同缓存条目。 +fn clamp_pagination(page: i32, per_page: i32) -> (i32, i32) { + ( + page.clamp(1, MAX_PAGE), + per_page.clamp(1, MAX_PER_PAGE), + ) +} + /// 获取已发布文章分页列表。 /// /// 优先命中缓存;未命中时查询总数与分页记录,并按 published_at 降序排列。 @@ -22,6 +45,9 @@ pub async fn list_published_posts( page: i32, per_page: i32, ) -> Result { + // 钳制分页参数,防止无认证调用方请求超大每页数量导致内存放大 / DoS。 + let (page, per_page) = clamp_pagination(page, per_page); + #[cfg(feature = "server")] { let cache_key = crate::cache::CacheKey::PublishedPosts { page, per_page }; @@ -93,6 +119,8 @@ pub async fn list_published_posts( /// 需要 admin 权限;结果按创建时间降序,不走缓存。 #[server(ListPosts, "/api")] pub async fn list_posts(page: i32, per_page: i32) -> Result { + // 与公开接口保持一致的分页钳制,避免单次请求拉取过多记录。 + let (page, per_page) = clamp_pagination(page, per_page); let _user = get_current_admin_user().await?; #[cfg(feature = "server")] @@ -199,3 +227,51 @@ pub async fn get_posts_by_tag(tag_name: String) -> Result