From fa8b1d996813fa5ae2719a7304267c6c72873afd Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 16 Jul 2026 11:55:58 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E8=A1=A5=E9=BD=90=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E5=85=B3=E9=94=AE=E8=B7=AF=E5=BE=84=E7=9A=84=E5=8D=95=E5=85=83?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E7=9B=B2=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 针对前期测试评估发现的高性价比纯函数与安全不变量盲区补测, 共新增 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 探测守卫。本次未改动它们。 --- src/api/code_runner/execute.rs | 88 ++++++++++++++++++++ src/api/database/export.rs | 145 +++++++++++++++++++++++++++++++++ src/api/sanitizer.rs | 145 +++++++++++++++++++++++++++++++++ src/infra/docker.rs | 83 +++++++++++++++++++ 4 files changed, 461 insertions(+) diff --git a/src/api/code_runner/execute.rs b/src/api/code_runner/execute.rs index 08f51ee..c9414b1 100644 --- a/src/api/code_runner/execute.rs +++ b/src/api/code_runner/execute.rs @@ -346,3 +346,91 @@ pub async fn get_exec_result(task_id: String) -> Result 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}"); + } +} diff --git a/src/api/database/export.rs b/src/api/database/export.rs index 33bf487..aaa27e8 100644 --- a/src/api/database/export.rs +++ b/src/api/database/export.rs @@ -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()); + } +} diff --git a/src/api/sanitizer.rs b/src/api/sanitizer.rs index a278716..3408fce 100644 --- a/src/api/sanitizer.rs +++ b/src/api/sanitizer.rs @@ -713,4 +713,149 @@ mod tests { "未知 data-* 应被剥离, got: {result}" ); } + + // ---- XSS 攻击向量回归:属性白名单是这里的核心防线 ---- + // 这些测试锁定"非白名单属性被剥离"这一不变量——若有人放宽属性过滤, + // 经典 XSS 向量就会重新可用,测试会在那一步失败。 + + #[test] + fn clean_html_strips_event_handler_attributes() { + // onerror/onload/onclick 等事件处理器属性全不在白名单,必须被移除。 + // 即便 本身合法,onerror 也不能留下。 + let cases = [ + r#""#, + r#""#, + r#""#, + r#"
x
"#, + r#"x"#, + r#""#, + ]; + 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#""#); + 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("

hi

bye

"); + 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_TAGS:CSS 注入(expression()、@import)随内容一起移除。 + let result = clean_html("

x

"); + 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)\">"); + 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#"x"#, + r#"x"#, + r#""#, + // 编码绕过尝试:HTML 实体编码的 javascript: + r#"x"#, + r#"x"#, + ]; + 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#""#); + 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=true,is_safe_data_uri 也只放行图片类型; + // data:text/html(可执行脚本)永远拒绝。直接测内部函数锁定该不变量。 + let schemes = DEFAULT_ALLOWED_SCHEMES.clone(); + assert!(!is_safe_url("data:text/html,", &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 内可嵌 ", + &schemes, + false + )); + } + + #[test] + fn clean_comment_html_strips_event_handlers() { + // 评论路径(更严格)同样不能漏掉事件处理器。 + let result = clean_comment_html(r#"

x

"#); + assert!(!result.contains("onclick"), "评论 XSS 向量: {result}"); + } } diff --git a/src/infra/docker.rs b/src/infra/docker.rs index 1d26959..a2f5b3e 100644 --- a/src/infra/docker.rs +++ b/src/infra/docker.rs @@ -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_000,memory_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]