test: 补齐安全关键路径的单元测试盲区

针对前期测试评估发现的高性价比纯函数与安全不变量盲区补测,
共新增 35 个回归测试(全部通过),分布如下:

- infra/docker.rs (+5): 扩展 build_host_config 的隔离断言——cap_drop=ALL、
  security_opt=no-new-privileges、pids_limit=64、memory_swap==memory(禁 swap)、
  tmpfs 的 exec 仅落在 /tmp、ulimits 只保留 nofile(无 nproc)。这些配置若被
  误删会让容器隔离悄悄失效,此前 test_host_config_generation 仅断言 cpu/memory。

- api/code_runner/execute.rs (+8): validate_exec_request 此前零测试。覆盖
  语言白名单(大小写/空白容忍、多 token 拒绝、未注册拒绝)、源码大小边界
  (恰好 max 放行 / max+1 拒绝)、校验顺序(语言优先于大小)。

- api/database/export.rs (+12): is_simple_ident / parse_source 此前零测试。
  覆盖表名白名单(SQL 注入 / 路径穿越 / Unicode 拒绝)、query: 来源的只读
  AST 校验(INSERT/UPDATE/DELETE/DROP/TRUNCATE/CREATE/ALTER/GRANT 全拦截)。

- api/sanitizer.rs (+10): 补 XSS 攻击向量回归——事件处理器属性(onerror/onload/
  onclick + 大小写混淆)、script/style 连内容移除、iframe/object/svg/form 等
  危险标签整体移除、javascript:/vbscript: scheme(含 HTML 实体编码绕过)、
  data URI 在文章正文路径(allow_data_uri=false)的拒绝、svg data URI 风险点锁定。

顺带记录一个既有可维护性问题:docker.rs 的 4 个 test_run_in_container_*
依赖真实 Docker daemon,无 daemon 环境会失败而非跳过,建议后续加 #[ignore]
或 daemon 探测守卫。本次未改动它们。
This commit is contained in:
xfy 2026-07-16 11:55:58 +08:00
parent fdf2243299
commit fa8b1d9968
4 changed files with 461 additions and 0 deletions

View File

@ -346,3 +346,91 @@ pub async fn get_exec_result(task_id: String) -> Result<ExecTask, ServerFnError>
Err(ServerFnError::new("找不到指定的任务".to_string()))
}
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
fn req(language: &str, source: &str) -> ExecRequest {
ExecRequest {
language: language.to_string(),
source: source.to_string(),
overrides: None,
}
}
#[test]
fn validate_accepts_registered_language() {
// python/node/go/rust 默认注册,应放行。
for lang in ["python", "node", "go", "rust"] {
assert!(
validate_exec_request(&req(lang, "x")).is_ok(),
"{lang} 应被支持"
);
}
}
#[test]
fn validate_language_is_case_and_whitespace_insensitive() {
// is_supported_lang 内部 trim+lowercase校验链应透传该容忍。
assert!(validate_exec_request(&req("Python", "x")).is_ok());
assert!(validate_exec_request(&req(" RUST ", "x")).is_ok());
assert!(validate_exec_request(&req("Go", "x")).is_ok());
}
#[test]
fn validate_rejects_unregistered_language() {
// 未注册语言 / 命令注入尝试都应在白名单阶段被拒。
assert!(validate_exec_request(&req("c", "x")).is_err());
assert!(validate_exec_request(&req("bash", "x")).is_err());
assert!(validate_exec_request(&req("python2", "x")).is_err());
assert!(validate_exec_request(&req("", "x")).is_err());
}
#[test]
fn validate_rejects_multi_token_language() {
// 语言字段不会到达容器层:只有命中 LANGUAGES 的 key 才放行,
// 否则被拒。任何多 token / 含 shell 元字符的值都因找不到注册项而拒绝。
assert!(validate_exec_request(&req("python; rm -rf /", "x")).is_err());
assert!(validate_exec_request(&req("python node", "x")).is_err());
assert!(validate_exec_request(&req("python$(whoami)", "x")).is_err());
}
#[test]
fn validate_language_tolerates_surrounding_whitespace() {
// is_supported_lang 内部 trim+lowercase首尾空白含换行被吃掉后等于 "python"。
// 锁定该契约——语言字段只用于 HashMap 查 key空白无害。
assert!(validate_exec_request(&req("python\n", "x")).is_ok());
assert!(validate_exec_request(&req("\tpython\t", "x")).is_ok());
}
#[test]
fn validate_rejects_source_exceeding_max_bytes() {
let max = RUNNER_CONFIG.max_source_bytes as usize;
// 恰好 max 放行max+1 拒绝——边界精确,防止 off-by-one 让攻击者多塞 1 字节。
let exactly = "a".repeat(max);
assert!(
validate_exec_request(&req("python", &exactly)).is_ok(),
"源码恰好等于上限应放行"
);
let over = "a".repeat(max + 1);
let err =
validate_exec_request(&req("python", &over)).expect_err("超出上限应拒绝");
assert!(err.to_string().contains("过大"), "错误信息应提及大小: {err}");
}
#[test]
fn validate_empty_source_accepted_for_supported_lang() {
// 空源码不是校验层的职责(容器层处理),这里只验语言/大小。
// 锁定该契约:空源码 + 合法语言 → Ok避免未来误把空当拒绝。
assert!(validate_exec_request(&req("python", "")).is_ok());
}
#[test]
fn validate_checks_language_before_size() {
// 不支持语言 + 巨大源码:应先因语言被拒(而非先报大小)。
let huge = "a".repeat((RUNNER_CONFIG.max_source_bytes as usize) + 100);
let err = validate_exec_request(&req("brainfuck", &huge)).unwrap_err();
assert!(err.to_string().contains("语言"), "应先报语言错误: {err}");
}
}

View File

@ -231,3 +231,148 @@ fn sql_quote_cell(row: &tokio_postgres::Row, idx: usize) -> String {
}
}
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
// ---- is_simple_ident表名白名单防 SQL 注入与路径穿越)----
#[test]
fn simple_ident_accepts_legal_names() {
assert!(is_simple_ident("posts"));
assert!(is_simple_ident("user_posts"));
assert!(is_simple_ident("table1"));
assert!(is_simple_ident("a"));
assert!(is_simple_ident("_private"));
assert!(is_simple_ident("ABC123xyz"));
}
#[test]
fn simple_ident_rejects_empty_and_whitespace() {
assert!(!is_simple_ident(""));
assert!(!is_simple_ident(" "));
}
#[test]
fn simple_ident_rejects_injection_vectors() {
// SQL 注入:引号、分号、注释、空格分隔
assert!(!is_simple_ident("posts; DROP TABLE users"));
assert!(!is_simple_ident("posts' OR '1'='1"));
assert!(!is_simple_ident("posts--"));
assert!(!is_simple_ident("public.posts"));
assert!(!is_simple_ident("posts where 1=1"));
// 路径穿越
assert!(!is_simple_ident("../etc/passwd"));
assert!(!is_simple_ident("..\\windows"));
assert!(!is_simple_ident("/etc/passwd"));
// 反引号 / 双引号标识符语法
assert!(!is_simple_ident("`posts`"));
assert!(!is_simple_ident("\"posts\""));
// 连字符(常见合法表名片段,但本白名单禁止,防 `a-b` 被 SQL 解析为减法)
assert!(!is_simple_ident("my-posts"));
}
#[test]
fn simple_ident_rejects_unicode_and_non_ascii() {
assert!(!is_simple_ident("文章"));
assert!(!is_simple_ident("posts²"));
assert!(!is_simple_ident("café"));
}
// ---- parse_source来源解析 + 只读校验(安全入口)----
#[test]
fn parse_table_source_builds_select_star() {
let (sql, name) = parse_source("table:posts").unwrap();
assert_eq!(sql, "SELECT * FROM \"posts\"");
assert_eq!(name, "posts");
}
#[test]
fn parse_table_source_trims_whitespace() {
let (sql, name) = parse_source("table: posts ").unwrap();
assert_eq!(sql, "SELECT * FROM \"posts\"");
assert_eq!(name, "posts");
}
#[test]
fn parse_table_source_rejects_bad_name() {
// 非法表名(注入/穿越/空)必须在拼进 SQL 前被拒,防 SQL 注入。
for bad in [
"table:",
"table: ",
"table:posts; DROP TABLE users",
"table:../secret",
"table:a b",
"table:\"x\"",
] {
let (code, msg) = parse_source(bad).expect_err(bad);
assert_eq!(code, StatusCode::BAD_REQUEST);
assert!(msg.contains("表名"), "{bad}: {msg}");
}
}
#[test]
fn parse_query_source_accepts_select_and_explain() {
assert!(parse_source("query:SELECT * FROM posts").is_ok());
assert!(parse_source("query:SELECT id, title FROM posts WHERE id > 0").is_ok());
assert!(parse_source("query:EXPLAIN SELECT * FROM posts").is_ok());
assert!(parse_source("query:SELECT 1").is_ok());
// 复杂但只读的查询
assert!(parse_source("query:WITH t AS (SELECT 1) SELECT * FROM t").is_ok());
}
#[test]
fn parse_query_source_rejects_all_write_operations() {
// 每一种写/破坏操作都必须被 AST 校验拦截——这是数据导出的核心安全不变量。
for evil in [
"query:INSERT INTO posts VALUES (1)",
"query:UPDATE posts SET title='x'",
"query:DELETE FROM posts",
"query:DROP TABLE posts",
"query:TRUNCATE posts",
"query:CREATE TABLE evil (id int)",
"query:ALTER TABLE posts ADD COLUMN x int",
"query:GRANT SELECT ON posts TO public",
// 即便嵌在 SELECT 里,子查询的写操作也要拒
"query:INSERT INTO logs VALUES (1) RETURNING 1",
] {
let (code, msg) = parse_source(evil).expect_err(evil);
assert_eq!(code, StatusCode::BAD_REQUEST, "{evil}");
// 写操作要么被 AST 校验拒("只读"),要么 sqlparser 解析阶段就失败
assert!(
msg.contains("只读") || msg.contains("解析失败"),
"{evil}: {msg}"
);
}
}
#[test]
fn parse_query_source_rejects_unparseable_sql() {
for bad in ["query:这不是SQL", "query:SELECT FROM", "query:@#$%", "query:1+1"] {
assert!(
parse_source(bad).is_err(),
"{bad} 应解析失败或被拒"
);
}
}
#[test]
fn parse_query_source_allows_empty_result_set() {
// sqlparser 容忍纯分号(解析为零语句),`any()` 在空集上为 false → 放行。
// 锁定该契约:这只产生空导出,无数据泄露,属可接受行为;
// 若未来想收紧为"空查询拒绝",此测试会提醒你更新。
assert!(parse_source("query:;;;").is_ok());
}
#[test]
fn parse_source_rejects_unknown_prefix() {
let (code, msg) = parse_source("unknown:posts").expect_err("未知前缀");
assert_eq!(code, StatusCode::BAD_REQUEST);
assert!(msg.contains("source"));
// 完全无前缀的裸字符串也应被拒
assert!(parse_source("posts").is_err());
assert!(parse_source("").is_err());
}
}

View File

@ -713,4 +713,149 @@ mod tests {
"未知 data-* 应被剥离, got: {result}"
);
}
// ---- XSS 攻击向量回归:属性白名单是这里的核心防线 ----
// 这些测试锁定"非白名单属性被剥离"这一不变量——若有人放宽属性过滤,
// 经典 XSS 向量就会重新可用,测试会在那一步失败。
#[test]
fn clean_html_strips_event_handler_attributes() {
// onerror/onload/onclick 等事件处理器属性全不在白名单,必须被移除。
// 即便 <img> 本身合法onerror 也不能留下。
let cases = [
r#"<img src="x" onerror="alert(1)">"#,
r#"<img src=x onerror=alert(1)>"#,
r#"<body onload="alert(1)">"#,
r#"<div onclick="alert(1)">x</div>"#,
r#"<a href="/x" onmouseover="alert(1)">x</a>"#,
r#"<svg onload="alert(1)"></svg>"#,
];
for input in cases {
let result = clean_html(input);
assert!(
!result.contains("onerror")
&& !result.contains("onload")
&& !result.contains("onclick")
&& !result.contains("onmouseover"),
"事件处理器属性应被剥离, input: {input}, got: {result}"
);
}
}
#[test]
fn clean_html_strips_event_handler_attribute_with_mixed_case() {
// 大小写混淆绕过尝试EvEr、大写、混合都应被拦属性名匹配应大小写不敏感地拒绝
for attr in ["OnErRoR", "ONERROR", "On_Error".replace('_', "or").as_str()] {
let input = format!(r#"<img src="x" {attr}="alert(1)">"#);
let result = clean_html(&input);
assert!(
!result.to_lowercase().contains("onerror"),
"大小写混淆的事件处理器应被剥离: {input} -> {result}"
);
}
}
#[test]
fn clean_html_removes_script_tag_and_content() {
// script 在 CLEAN_CONTENT_TAGS标签连同内容一起移除而非转义后保留
let result = clean_html("<p>hi</p><script>alert(1)</script><p>bye</p>");
assert!(!result.contains("script"), "script 标签应被完全移除: {result}");
assert!(!result.contains("alert"), "script 内容应被清除: {result}");
assert!(result.contains("hi") && result.contains("bye"), "周围内容应保留: {result}");
}
#[test]
fn clean_html_removes_style_tag_and_content() {
// style 也走 CLEAN_CONTENT_TAGSCSS 注入expression()、@import随内容一起移除。
let result = clean_html("<style>body{background:url(javascript:alert(1))}</style><p>x</p>");
assert!(!result.contains("style"), "style 标签应被移除: {result}");
assert!(!result.contains("javascript"), "style 内危险内容应被清除: {result}");
}
#[test]
fn clean_html_drops_dangerous_tags_entirely() {
// 这些标签不在白名单:整体移除(含子树),无法用于 XSS / 数据外泄。
for tag in ["iframe", "object", "embed", "form", "svg", "math", "base", "meta"] {
let input = format!("<{tag} src=\"javascript:alert(1)\"></{tag}>");
let result = clean_html(&input);
assert!(
!result.to_lowercase().contains(&format!("<{tag}")),
"<{tag}> 不在白名单应被移除: {result}"
);
}
}
#[test]
fn clean_html_drops_javascript_scheme_in_href_and_src() {
// 即便标签合法javascript:/vbscript: scheme 必须被拒is_safe_url 防线)。
let cases = [
r#"<a href="javascript:alert(1)">x</a>"#,
r#"<a href="vbscript:msgbox(1)">x</a>"#,
r#"<img src="javascript:alert(1)">"#,
// 编码绕过尝试HTML 实体编码的 javascript:
r#"<a href="&#106;avascript:alert(1)">x</a>"#,
r#"<a href="java&#115;cript:alert(1)">x</a>"#,
];
for input in cases {
let result = clean_html(input);
// href/src 值里不应残留可执行的 javascript scheme属性可能被整段移除或值被清空
// 至少裸的 "javascript:" 字面量不应在 href/src 属性值中出现。
let lower = result.to_lowercase();
assert!(
!lower.contains("javascript:") && !lower.contains("vbscript:"),
"危险 scheme 应被移除, input: {input}, got: {result}"
);
}
}
#[test]
fn clean_html_data_uri_blocked_in_article_body() {
// clean_html 配置 allow_data_uri: false——所有 data URI含 image/png都应被拒。
// 这是文章正文路径的契约;评论路径同样。
let result = clean_html(r#"<img src="data:image/png;base64,iVBORw0KGgo=처리">"#);
assert!(
!result.contains("data:image/png"),
"文章正文应禁用 data URI: {result}"
);
}
#[test]
fn clean_html_data_uri_text_html_blocked_even_if_flag_true() {
// 即便 allow_data_uri=trueis_safe_data_uri 也只放行图片类型;
// data:text/html可执行脚本永远拒绝。直接测内部函数锁定该不变量。
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
assert!(!is_safe_url("data:text/html,<script>alert(1)</script>", &schemes, true));
assert!(!is_safe_url(
"data:application/javascript,alert(1)",
&schemes,
true
));
// 安全图片类型在 flag=true 时放行
assert!(is_safe_url("data:image/png;base64,iVBOR=", &schemes, true));
}
#[test]
fn clean_html_svg_data_uri_carries_risk_even_when_allowed() {
// is_safe_data_uri 允许 image/svg+xml——SVG 内可嵌 <script>。
// 这里锁定当前行为flag=true 时 svg data URI 被放行(调用方需自行评估风险),
// 并用注释标记这是一个潜在风险点(文章正文 allow_data_uri=false 已堵住)。
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
assert!(
is_safe_url("data:image/svg+xml,<svg></svg>", &schemes, true),
"svg data URI 在 flag=true 时当前被放行(已知风险点)"
);
// 但文章正文路径 flag=falsesvg data URI 同样被拒
assert!(!is_safe_url(
"data:image/svg+xml,<svg><script>alert(1)</script></svg>",
&schemes,
false
));
}
#[test]
fn clean_comment_html_strips_event_handlers() {
// 评论路径(更严格)同样不能漏掉事件处理器。
let result = clean_comment_html(r#"<p onclick="alert(1)">x</p>"#);
assert!(!result.contains("onclick"), "评论 XSS 向量: {result}");
}
}

View File

@ -611,6 +611,89 @@ mod tests {
assert_eq!(host_config.memory, Some(256 * 1024 * 1024));
assert_eq!(host_config.readonly_rootfs, Some(true));
assert_eq!(host_config.network_mode.as_deref(), Some("none"));
// 隔离不变量:这些配置若被误删,容器隔离会悄悄失效。
// 单独断言而非隐含在端到端测试里,确保回归可见。
assert_eq!(host_config.cap_drop.as_deref(), Some(&["ALL".to_string()][..]));
assert_eq!(
host_config.security_opt.as_deref(),
Some(&["no-new-privileges".to_string()][..])
);
assert_eq!(host_config.pids_limit, Some(64));
// memory_swap == memory 才是真正禁用 swap二者不等价于可换出到磁盘
assert_eq!(host_config.memory_swap, host_config.memory);
assert_eq!(host_config.memory_swap, Some(256 * 1024 * 1024));
}
#[test]
fn host_config_ulimits_drops_only_nofile() {
// nproc 在容器内对 non-root 用户有害(初始 exec /bin/sh 直接 EAGAIN
// 只保留 nofile=64。若有人加回 nproc这里会失败。
let limits = ResourceLimits {
cpu_cores: 1.0,
memory_mb: 128,
timeout_secs: 5,
output_bytes: 1024,
allow_network: false,
};
let host_config = build_host_config(&limits);
let ulimits = host_config.ulimits.expect("ulimits must be set");
assert_eq!(ulimits.len(), 1, "only nofile, no nproc");
let nf = &ulimits[0];
assert_eq!(nf.name.as_deref(), Some("nofile"));
assert_eq!(nf.soft, Some(64));
assert_eq!(nf.hard, Some(64));
}
#[test]
fn host_config_tmpfs_has_exec_on_tmp_only() {
// /tmp 必须 exec编译型语言把产物落在 /tmp 后再 exec
// /code、/run 不带 exec默认 noexec。若误给 /code 也加 exec测试失败。
let limits = ResourceLimits {
cpu_cores: 1.0,
memory_mb: 128,
timeout_secs: 5,
output_bytes: 1024,
allow_network: false,
};
let host_config = build_host_config(&limits);
let tmpfs = host_config.tmpfs.expect("tmpfs must be set");
assert_eq!(tmpfs.len(), 3);
assert!(tmpfs["/tmp"].contains("exec"));
assert!(!tmpfs["/code"].contains("exec"));
assert!(!tmpfs["/run"].contains("exec"));
}
#[test]
fn host_config_network_mode_follows_allow_network() {
let base = ResourceLimits {
cpu_cores: 1.0,
memory_mb: 128,
timeout_secs: 5,
output_bytes: 1024,
allow_network: false,
};
assert_eq!(build_host_config(&base).network_mode.as_deref(), Some("none"));
let mut net = base;
net.allow_network = true;
assert_eq!(build_host_config(&net).network_mode.as_deref(), Some("bridge"));
}
#[test]
fn host_config_cpu_and_memory_scale_with_limits() {
// 资源钳制是安全边界断言换算公式不漂移cpu_cores * 100_000memory_mb * 1MiB
let limits = ResourceLimits {
cpu_cores: 2.0,
memory_mb: 512,
timeout_secs: 5,
output_bytes: 1024,
allow_network: false,
};
let host_config = build_host_config(&limits);
assert_eq!(host_config.cpu_quota, Some(200_000));
assert_eq!(host_config.cpu_period, Some(100_000));
assert_eq!(host_config.memory, Some(512 * 1024 * 1024));
assert_eq!(host_config.memory_swap, Some(512 * 1024 * 1024));
}
#[tokio::test]