Merge branch 'fix-dos-and-toc-xss'
安全修复: - 钳制公开分页接口 per_page 与 page 上限,消除 DoS 与缓存键扇出 - 修正 TOC 标题在属性上下文的转义,防止属性注入(XSS) 经 code review 后追加: - 复用既有 escape_html,删除重复转义实现 - 增加 page 上限作为纵深防御
This commit is contained in:
commit
a2ccc3da1a
@ -12,6 +12,16 @@ pub fn clean_html(input: &str) -> String {
|
||||
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)]
|
||||
#[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!(
|
||||
"<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);
|
||||
}
|
||||
|
||||
#[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**");
|
||||
|
||||
@ -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<PostListResponse, ServerFnError> {
|
||||
// 钳制分页参数,防止无认证调用方请求超大每页数量导致内存放大 / 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<PostListResponse, ServerFnError> {
|
||||
// 与公开接口保持一致的分页钳制,避免单次请求拉取过多记录。
|
||||
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<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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user