From cfa49758139705c25b4d3dfbf25050a6cc20c8b0 Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 15 Jun 2026 11:27:25 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(posts):=20=E9=92=B3=E5=88=B6=E5=88=86?= =?UTF-8?q?=E9=A1=B5=E5=8F=82=E6=95=B0=20per=5Fpage=20=E4=B8=8A=E9=99=90?= =?UTF-8?q?=EF=BC=8C=E6=B6=88=E9=99=A4=E5=85=AC=E5=BC=80=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=20DoS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list_published_posts 为无需认证的公开接口,原先 per_page 直接透传给 SQL LIMIT,攻击者可传入巨大值迫使数据库扫描并实例化超大 Vec,造成 内存放大与拒绝服务。新增 MAX_PER_PAGE=50 与 clamp_pagination(),在 list_published_posts 与 list_posts 入口钳制 page/per_page;钳制值同时 用于缓存键与查询,避免同一逻辑页落入不同缓存条目。 --- src/api/posts/list.rs | 53 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/api/posts/list.rs b/src/api/posts/list.rs index 73b4260..ab31697 100644 --- a/src/api/posts/list.rs +++ b/src/api/posts/list.rs @@ -14,6 +14,19 @@ 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; + +/// 将分页参数钳制到安全范围:页码至少为 1,每页 1–`MAX_PER_PAGE`。 +/// +/// 注意:返回值必须同时用于缓存键与 SQL 查询,避免同一逻辑页落入不同缓存条目。 +fn clamp_pagination(page: i32, per_page: i32) -> (i32, i32) { + (page.max(1), per_page.clamp(1, MAX_PER_PAGE)) +} + /// 获取已发布文章分页列表。 /// /// 优先命中缓存;未命中时查询总数与分页记录,并按 published_at 降序排列。 @@ -22,6 +35,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 +109,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 +217,38 @@ pub async fn get_posts_by_tag(tag_name: String) -> Result Date: Mon, 15 Jun 2026 11:27:32 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(markdown):=20=E8=BD=AC=E4=B9=89=20TOC?= =?UTF-8?q?=20=E6=A0=87=E9=A2=98=E6=96=87=E6=9C=AC=EF=BC=8C=E9=98=B2?= =?UTF-8?q?=E6=AD=A2=E5=B1=9E=E6=80=A7=E4=B8=8A=E4=B8=8B=E6=96=87=E6=B3=A8?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generate_toc_html 原先用 clean_html 处理标题后拼进 aria-label="..." 与 正文,但 clean_html 只做正文 HTML 消毒、不转义双引号。标题形如 " onmouseover="alert(1) 会越出属性边界。新增 escape_html_attr(),对 标题文本统一做属性上下文转义(& " <),正文与属性两处一致使用。 内容由 admin 写入、严重度中低,但属真实消毒逻辑缺口,TOC 会展示给所有读者。 --- src/api/markdown.rs | 55 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/src/api/markdown.rs b/src/api/markdown.rs index 83d7a67..1f498e1 100644 --- a/src/api/markdown.rs +++ b/src/api/markdown.rs @@ -12,6 +12,18 @@ pub fn clean_html(input: &str) -> String { crate::api::sanitizer::clean_html(input) } +#[cfg(feature = "server")] +/// 将文本转义为可安全放入 HTML **属性值**(双引号包裹)的形式。 +/// +/// `clean_html` 用于消毒正文 HTML,但把文本拼进 `attr="..."` 时不会转义双引号, +/// 形如标题 `" onmouseover="alert(1)` 会越出属性边界导致属性注入。此处补齐 +/// `&` / `"` / `<` 的属性上下文转义。 +fn escape_html_attr(s: &str) -> String { + s.replace('&', "&") + .replace('"', """) + .replace('<', "<") +} + #[derive(Debug, Clone)] #[cfg(feature = "server")] /// Markdown 渲染结果。 @@ -222,10 +234,13 @@ fn generate_toc_html(headings: &[(u8, String, String)]) -> String { } } - let clean_text = clean_html(text); + // 标题 text 是 pulldown-cmark 收集的纯文本(Text/Code 字面字符),不是 HTML 片段, + // 因此正文与属性两处都用属性转义(& " <)。原先用 clean_html 处理正文会漏掉 `"`, + // 虽然文本节点中的 `"` 不会闭合属性,但统一转义更稳健、可读性更好。 + let escaped_text = escape_html_attr(text); html.push_str(&format!( "
  • {}", - id, clean_text, clean_text + id, escaped_text, escaped_text )); } @@ -357,6 +372,42 @@ 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 render_markdown_simple_paragraph() { let result = render_markdown_enhanced("Hello **world**"); From eaa7118e0922ddcea343c54370c0605b3e8b0fda Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 15 Jun 2026 11:38:24 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(posts):=20=E9=92=B3=E5=88=B6=20page=20?= =?UTF-8?q?=E4=B8=8A=E9=99=90=EF=BC=8C=E9=98=B2=E6=AD=A2=E6=97=A0=E7=95=8C?= =?UTF-8?q?=20OFFSET=20=E4=B8=8E=E7=BC=93=E5=AD=98=E9=94=AE=E6=89=87?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clamp_pagination 原先仅限制 per_page,page 无上限。攻击者可用海量不同 page 值撑大缓存键空间(缓存污染)并触发无意义的超大 OFFSET 扫描。新增 MAX_PAGE=10_000,对任何实际博客都足够宽裕(配合 MAX_PER_PAGE 最多覆盖 50 万篇文章),同时把缓存键空间限制在有限范围。 --- src/api/posts/list.rs | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/api/posts/list.rs b/src/api/posts/list.rs index ab31697..a7e740d 100644 --- a/src/api/posts/list.rs +++ b/src/api/posts/list.rs @@ -20,11 +20,21 @@ use crate::db::pool::get_conn; /// 攻击者可传入巨大值迫使数据库扫描并实例化超大 Vec,造成内存放大与拒绝服务。 const MAX_PER_PAGE: i32 = 50; -/// 将分页参数钳制到安全范围:页码至少为 1,每页 1–`MAX_PER_PAGE`。 +/// 允许的最大页码。 +/// +/// `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.max(1), per_page.clamp(1, MAX_PER_PAGE)) + ( + page.clamp(1, MAX_PAGE), + per_page.clamp(1, MAX_PER_PAGE), + ) } /// 获取已发布文章分页列表。 @@ -243,6 +253,19 @@ mod tests { assert_eq!(clamp_pagination(1, -100), (1, 1)); } + #[test] + fn clamp_pagination_clamps_oversized_page() { + // 巨大 page 必须被压回上限,避免无界 OFFSET 扫描与缓存键扇出。 + assert_eq!(clamp_pagination(i32::MAX, 10), (MAX_PAGE, 10)); + assert_eq!(clamp_pagination(MAX_PAGE + 1, 10), (MAX_PAGE, 10)); + } + + #[test] + fn clamp_pagination_max_page_boundary() { + assert_eq!(clamp_pagination(MAX_PAGE, 10), (MAX_PAGE, 10)); + assert_eq!(clamp_pagination(MAX_PAGE - 1, 10), (MAX_PAGE - 1, 10)); + } + #[test] fn clamp_pagination_max_per_page_boundary() { assert_eq!(clamp_pagination(1, MAX_PER_PAGE), (1, MAX_PER_PAGE)); From a3d6c2f19ee5db106cde96e59bb3af49c002fa56 Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 15 Jun 2026 11:38:24 +0800 Subject: [PATCH 4/4] =?UTF-8?q?refactor(markdown):=20=E5=A4=8D=E7=94=A8?= =?UTF-8?q?=E6=97=A2=E6=9C=89=20escape=5Fhtml=EF=BC=8C=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E7=9A=84=E8=BD=AC=E4=B9=89=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hooks::comment_storage::escape_html 已实现完整 OWASP 转义(& < > " '), 且无 server feature 门控,全平台可用。escape_heading_text 改为委托给它, 避免在仓库内维护第二份行为略有差异(缺 >)的转义函数。补充 < 转义测试。 --- src/api/markdown.rs | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/src/api/markdown.rs b/src/api/markdown.rs index 1f498e1..b26320a 100644 --- a/src/api/markdown.rs +++ b/src/api/markdown.rs @@ -13,15 +13,13 @@ pub fn clean_html(input: &str) -> String { } #[cfg(feature = "server")] -/// 将文本转义为可安全放入 HTML **属性值**(双引号包裹)的形式。 +/// 将标题纯文本转义,用于安全地拼进 TOC 的 `aria-label="..."` 与 `` 正文。 /// -/// `clean_html` 用于消毒正文 HTML,但把文本拼进 `attr="..."` 时不会转义双引号, -/// 形如标题 `" onmouseover="alert(1)` 会越出属性边界导致属性注入。此处补齐 -/// `&` / `"` / `<` 的属性上下文转义。 -fn escape_html_attr(s: &str) -> String { - s.replace('&', "&") - .replace('"', """) - .replace('<', "<") +/// 复用 `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)] @@ -235,9 +233,9 @@ fn generate_toc_html(headings: &[(u8, String, String)]) -> String { } // 标题 text 是 pulldown-cmark 收集的纯文本(Text/Code 字面字符),不是 HTML 片段, - // 因此正文与属性两处都用属性转义(& " <)。原先用 clean_html 处理正文会漏掉 `"`, - // 虽然文本节点中的 `"` 不会闭合属性,但统一转义更稳健、可读性更好。 - let escaped_text = escape_html_attr(text); + // 因此正文与属性两处都走 escape_heading_text(转义 & < > " ')。原先用 clean_html + // 处理属性上下文会漏掉 `"`,标题中的双引号会越出 aria-label 边界。 + let escaped_text = escape_heading_text(text); html.push_str(&format!( "
  • {}", id, escaped_text, escaped_text @@ -408,6 +406,18 @@ mod tests { ); } + #[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**");