Merge branch 'fix-dos-and-toc-xss'

安全修复:
- 钳制公开分页接口 per_page 与 page 上限,消除 DoS 与缓存键扇出
- 修正 TOC 标题在属性上下文的转义,防止属性注入(XSS)

经 code review 后追加:
- 复用既有 escape_html,删除重复转义实现
- 增加 page 上限作为纵深防御
This commit is contained in:
xfy 2026-06-15 11:40:01 +08:00
commit a2ccc3da1a
2 changed files with 139 additions and 2 deletions

View File

@ -12,6 +12,16 @@ pub fn clean_html(input: &str) -> String {
crate::api::sanitizer::clean_html(input) crate::api::sanitizer::clean_html(input)
} }
#[cfg(feature = "server")]
/// 将标题纯文本转义,用于安全地拼进 TOC 的 `aria-label="..."` 与 `<a>` 正文。
///
/// 复用 `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)] #[derive(Debug, Clone)]
#[cfg(feature = "server")] #[cfg(feature = "server")]
/// Markdown 渲染结果。 /// 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!( html.push_str(&format!(
"<li><a href=\"#{}\" aria-label=\"{}\">{}</a>", "<li><a href=\"#{}\" aria-label=\"{}\">{}</a>",
id, clean_text, clean_text id, escaped_text, escaped_text
)); ));
} }
@ -357,6 +370,54 @@ mod tests {
assert_eq!(ul_count, 2); 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 中的双引号被转义为 &quot;,无法越出属性边界注入新属性。
assert!(
html.contains("aria-label=\"&quot; onmouseover=&quot;alert(1)\""),
"aria-label 应转义内部双引号got: {html}"
);
// 关键:不得出现「未被引号包裹、可被解析为真实属性」的 onmouseover= 片段。
// 正文中作为纯文本出现 "onmouseover" 字符串是安全的(无 < 或属性结构)。
let attr_injection = "\" onmouseover=\"";
let injected = html.matches(attr_injection).count();
// 原始输入里有 1 个裸双引号起头;转义后该模式不应再作为属性边界出现。
// 注意 aria-label 内部的双引号已变成 &quot;,因此裸的 `" 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 &amp; 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 &lt; b\""),
"< 应在属性中转义got: {html}"
);
assert!(!html.contains("a < b"));
}
#[test] #[test]
fn render_markdown_simple_paragraph() { fn render_markdown_simple_paragraph() {
let result = render_markdown_enhanced("Hello **world**"); let result = render_markdown_enhanced("Hello **world**");

View File

@ -14,6 +14,29 @@ use super::types::PostListResponse;
use crate::api::error::AppError; use crate::api::error::AppError;
use crate::db::pool::get_conn; 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 降序排列。 /// 优先命中缓存;未命中时查询总数与分页记录,并按 published_at 降序排列。
@ -22,6 +45,9 @@ pub async fn list_published_posts(
page: i32, page: i32,
per_page: i32, per_page: i32,
) -> Result<PostListResponse, ServerFnError> { ) -> Result<PostListResponse, ServerFnError> {
// 钳制分页参数,防止无认证调用方请求超大每页数量导致内存放大 / DoS。
let (page, per_page) = clamp_pagination(page, per_page);
#[cfg(feature = "server")] #[cfg(feature = "server")]
{ {
let cache_key = crate::cache::CacheKey::PublishedPosts { page, per_page }; let cache_key = crate::cache::CacheKey::PublishedPosts { page, per_page };
@ -93,6 +119,8 @@ pub async fn list_published_posts(
/// 需要 admin 权限;结果按创建时间降序,不走缓存。 /// 需要 admin 权限;结果按创建时间降序,不走缓存。
#[server(ListPosts, "/api")] #[server(ListPosts, "/api")]
pub async fn list_posts(page: i32, per_page: i32) -> Result<PostListResponse, ServerFnError> { pub async fn list_posts(page: i32, per_page: i32) -> Result<PostListResponse, ServerFnError> {
// 与公开接口保持一致的分页钳制,避免单次请求拉取过多记录。
let (page, per_page) = clamp_pagination(page, per_page);
let _user = get_current_admin_user().await?; let _user = get_current_admin_user().await?;
#[cfg(feature = "server")] #[cfg(feature = "server")]
@ -199,3 +227,51 @@ pub async fn get_posts_by_tag(tag_name: String) -> Result<PostListResponse, Serv
}) })
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clamp_pagination_keeps_valid_values() {
assert_eq!(clamp_pagination(1, 10), (1, 10));
assert_eq!(clamp_pagination(3, 20), (3, 20));
}
#[test]
fn clamp_pagination_clamps_oversized_per_page() {
// 攻击者传入超大 per_page 必须被压回上限,避免内存放大 / DoS。
assert_eq!(clamp_pagination(1, 1_000_000_000), (1, MAX_PER_PAGE));
assert_eq!(clamp_pagination(2, 51), (2, MAX_PER_PAGE));
}
#[test]
fn clamp_pagination_clamps_non_positive() {
assert_eq!(clamp_pagination(0, 10), (1, 10));
assert_eq!(clamp_pagination(-5, 10), (1, 10));
assert_eq!(clamp_pagination(1, 0), (1, 1));
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));
assert_eq!(
clamp_pagination(1, MAX_PER_PAGE - 1),
(1, MAX_PER_PAGE - 1)
);
}
}