Compare commits
3 Commits
d1c41e8fc5
...
debe7dabe3
| Author | SHA1 | Date | |
|---|---|---|---|
| debe7dabe3 | |||
| fa8b1d9968 | |||
| fdf2243299 |
17
.env.example
17
.env.example
@ -55,6 +55,10 @@ TRUSTED_PROXY_COUNT=0
|
|||||||
# 仅对经连接池(DB_POOL)建立的连接生效;作用是防止单条慢查询长时间占用连接拖垮池。
|
# 仅对经连接池(DB_POOL)建立的连接生效;作用是防止单条慢查询长时间占用连接拖垮池。
|
||||||
# 需注意:PostgreSQL 的总连接数受 max_connections 限制,本超时不影响已占连接的计费。
|
# 需注意:PostgreSQL 的总连接数受 max_connections 限制,本超时不影响已占连接的计费。
|
||||||
STATEMENT_TIMEOUT_SECS=30
|
STATEMENT_TIMEOUT_SECS=30
|
||||||
|
# 是否附加版本暴露响应头(Server / X-Yggdrasil-Version / X-Yggdrasil-Git)。
|
||||||
|
# 默认开启;注重安全、不想对外暴露具体版本/commit 时设 0/false/no 关闭。
|
||||||
|
# bool 解析与 COOKIE_SECURE 一致(识别 0/false/no 为关闭,其余视为开启)。
|
||||||
|
EXPOSE_VERSION_HEADERS=true
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
# WebP 编码配置(仅上传转码 / 图片格式转换时生效)
|
# WebP 编码配置(仅上传转码 / 图片格式转换时生效)
|
||||||
@ -87,6 +91,11 @@ MIGRATE_STARTUP_TIMEOUT_SECS=30
|
|||||||
# 此 TTL 是唯一有效的 SSR 缓存失效手段——意味着文章更新后最长可能滞后 TTL 秒才可见。
|
# 此 TTL 是唯一有效的 SSR 缓存失效手段——意味着文章更新后最长可能滞后 TTL 秒才可见。
|
||||||
SSR_CACHE_SECS=3600
|
SSR_CACHE_SECS=3600
|
||||||
|
|
||||||
|
# 系统信息后台采样间隔,单位秒(默认 0.5)。
|
||||||
|
# 仅影响 /admin/system 服务器状态页的刷新粒度;服务端函数读取快照零成本,前端可高频轮询。
|
||||||
|
# 支持小数(如 0.2),下限 0.05;过低会徒增 CPU 占用而无可见收益。
|
||||||
|
SYSINFO_SAMPLE_SECS=0.5
|
||||||
|
|
||||||
# HTTP 响应压缩算法。逗号分隔,大小写不敏感。
|
# HTTP 响应压缩算法。逗号分隔,大小写不敏感。
|
||||||
# 支持:gzip、brotli(可简写为 br)、deflate、zstd。
|
# 支持:gzip、brotli(可简写为 br)、deflate、zstd。
|
||||||
# 默认关闭压缩(不设环境变量时等同 "off")。
|
# 默认关闭压缩(不设环境变量时等同 "off")。
|
||||||
@ -141,6 +150,14 @@ CODE_RUNNER_MAX_CPU_CORES=2.0
|
|||||||
CODE_RUNNER_MAX_MEMORY_MB=1024
|
CODE_RUNNER_MAX_MEMORY_MB=1024
|
||||||
CODE_RUNNER_MAX_TIMEOUT_SECS=30
|
CODE_RUNNER_MAX_TIMEOUT_SECS=30
|
||||||
CODE_RUNNER_MAX_OUTPUT_BYTES=1048576
|
CODE_RUNNER_MAX_OUTPUT_BYTES=1048576
|
||||||
|
# 容器可接收的源码字节上限(默认 65536)。超过即拒绝,防止超大输入撑爆容器。
|
||||||
|
CODE_RUNNER_MAX_SOURCE_BYTES=65536
|
||||||
|
# 任务排队等容器槽的最长时间,单位秒(默认 30)。
|
||||||
|
# 受 CODE_RUNNER_MAX_CONCURRENT 限制,并发满时新任务排队,超时则失败。
|
||||||
|
CODE_RUNNER_QUEUE_TIMEOUT_SECS=30
|
||||||
|
# 进度表(DashMap)中任务条目的存活时长,单位秒(默认 300)。
|
||||||
|
# 超期后任务记录被 gc_old_tasks 清理;前端轮询结果需在此窗口内完成。
|
||||||
|
CODE_RUNNER_TASK_TTL_SECS=300
|
||||||
# 默认全开:注册表里的语言均可用。设置则收窄到逗号分隔列表(如 python,node)。
|
# 默认全开:注册表里的语言均可用。设置则收窄到逗号分隔列表(如 python,node)。
|
||||||
# CODE_RUNNER_LANGUAGES=python,node
|
# CODE_RUNNER_LANGUAGES=python,node
|
||||||
DOCKER_SOCKET_PATH=/var/run/docker.sock
|
DOCKER_SOCKET_PATH=/var/run/docker.sock
|
||||||
|
|||||||
@ -346,3 +346,91 @@ pub async fn get_exec_result(task_id: String) -> Result<ExecTask, ServerFnError>
|
|||||||
Err(ServerFnError::new("找不到指定的任务".to_string()))
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -713,4 +713,149 @@ mod tests {
|
|||||||
"未知 data-* 应被剥离, got: {result}"
|
"未知 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_TAGS:CSS 注入(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="javascript:alert(1)">x</a>"#,
|
||||||
|
r#"<a href="javascript: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=true,is_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=false,svg 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}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -611,11 +611,112 @@ mod tests {
|
|||||||
assert_eq!(host_config.memory, Some(256 * 1024 * 1024));
|
assert_eq!(host_config.memory, Some(256 * 1024 * 1024));
|
||||||
assert_eq!(host_config.readonly_rootfs, Some(true));
|
assert_eq!(host_config.readonly_rootfs, Some(true));
|
||||||
assert_eq!(host_config.network_mode.as_deref(), Some("none"));
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 探测 Docker daemon 是否可用:socket 缺失(`DOCKER_CLIENT == None`)或
|
||||||
|
/// daemon 无响应时返回 `None`。集成测试用它做动态守卫——daemon 在则跑,
|
||||||
|
/// 不在则显式跳过(eprintln + 提前 return),而非 panic 失败。
|
||||||
|
///
|
||||||
|
/// 这比 `#[ignore]` 更合适:`#[ignore]` 会在所有环境(含有 Docker 的开发机)
|
||||||
|
/// 一律跳过、且需 `cargo test --ignored` 显式触发;探测守卫让这些测试在
|
||||||
|
/// 有 Docker 时自动运行、在无 Docker 的 CI 上静默放行。
|
||||||
|
async fn require_docker() -> Option<&'static Docker> {
|
||||||
|
let docker = DOCKER_CLIENT.as_ref()?;
|
||||||
|
// socket 在但 daemon 挂了的情况靠这一步轻量只读调用兜住。
|
||||||
|
docker.version().await.ok()?;
|
||||||
|
Some(docker)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
async fn test_run_in_container_success() {
|
async fn test_run_in_container_success() {
|
||||||
|
if require_docker().await.is_none() {
|
||||||
|
eprintln!("skip: Docker daemon 不可用(未安装或未运行)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
let limits = ResourceLimits {
|
let limits = ResourceLimits {
|
||||||
cpu_cores: 1.0,
|
cpu_cores: 1.0,
|
||||||
memory_mb: 128,
|
memory_mb: 128,
|
||||||
@ -642,6 +743,10 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
async fn test_run_in_container_output_truncation() {
|
async fn test_run_in_container_output_truncation() {
|
||||||
|
if require_docker().await.is_none() {
|
||||||
|
eprintln!("skip: Docker daemon 不可用(未安装或未运行)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
let limits = ResourceLimits {
|
let limits = ResourceLimits {
|
||||||
cpu_cores: 1.0,
|
cpu_cores: 1.0,
|
||||||
memory_mb: 128,
|
memory_mb: 128,
|
||||||
@ -668,6 +773,10 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
async fn test_run_in_container_timeout() {
|
async fn test_run_in_container_timeout() {
|
||||||
|
if require_docker().await.is_none() {
|
||||||
|
eprintln!("skip: Docker daemon 不可用(未安装或未运行)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
let limits = ResourceLimits {
|
let limits = ResourceLimits {
|
||||||
cpu_cores: 1.0,
|
cpu_cores: 1.0,
|
||||||
memory_mb: 128,
|
memory_mb: 128,
|
||||||
@ -691,7 +800,13 @@ mod tests {
|
|||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
async fn test_run_in_container_cancellation() {
|
async fn test_run_in_container_cancellation() {
|
||||||
use bollard::query_parameters::ListContainersOptions;
|
use bollard::query_parameters::ListContainersOptions;
|
||||||
let docker = get_docker().expect("test requires a running Docker daemon");
|
let docker = match require_docker().await {
|
||||||
|
Some(d) => d,
|
||||||
|
None => {
|
||||||
|
eprintln!("skip: Docker daemon 不可用(未安装或未运行)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let before = docker
|
let before = docker
|
||||||
.list_containers(Some(ListContainersOptions {
|
.list_containers(Some(ListContainersOptions {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user