fix: 远程目录 ~ 展开改用 $HOME(shlex.quote 会把 ~ 包进单引号不展开)

- _resolve_remote_dir → _rel_remote_dir 返回纯相对路径
- rsync 用 :~/,ssh mkdir/bwrap --bind 用 $HOME/rel 让远程 shell 展开
- 加强 remote_dir 安全字符校验(_REL_DIR_RE,防 shell 注入)
This commit is contained in:
ymq 2026-08-14 14:26:29 +08:00
parent cfaf48ed5d
commit 70185771d0

View File

@ -133,12 +133,14 @@ _HOST_RE = re.compile(r"^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$")
_USER_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
# remote_dir 现在是相对路径(从远程登录用户的 home 目录开始)。
# 相对路径天然限制在 home 内,无需系统目录黑名单,只需防路径穿越(..
# 相对路径天然限制在 home 内,无需系统目录黑名单,只需防路径穿越(..与 shell 注入
def _resolve_remote_dir(remote_dir: str) -> str:
"""把相对路径规范化为远程 home 下的路径(~/xxx"""
rel = (remote_dir or "").strip().strip("/")
return "~/" + rel if rel else "~"
_REL_DIR_RE = re.compile(r"^[a-zA-Z0-9._/-]+$")
def _rel_remote_dir(remote_dir: str) -> str:
"""规范化相对路径strip 前后 /),返回纯相对路径(不含 ~,避免 shlex.quote 后 ~ 不展开)。"""
return (remote_dir or "").strip().strip("/")
def _validate_remote_dir(remote_dir: str) -> str:
@ -153,6 +155,8 @@ def _validate_remote_dir(remote_dir: str) -> str:
return "remote_dir 不能包含 .."
if not parts:
return "remote_dir 不能为空"
if not _REL_DIR_RE.match(d):
return "remote_dir 只能含字母数字和 . _ - / 字符"
return None
@ -205,10 +209,14 @@ def _validate_remote(remote_config: dict) -> str:
# ═══════════════════════════════════════════════════════════
async def _remote_mkdir(env: dict, remote_dir: str) -> bool:
"""远程 mkdir -p相对路径可能多级rsync 不会递归建父目录)。失败返回 False。"""
"""远程 mkdir -p相对路径可能多级rsync 不会递归建父目录)。失败返回 False。
remote_dir 已校验为安全字符_REL_DIR_RE $HOME 拼接让远程 shell 展开
不用 ~ 前缀 shlex.quote 会把 ~ 包进单引号导致不展开
"""
try:
target = _ssh_target(env)
ssh_cmd = ["ssh"] + _ssh_common_args(env) + [target, "mkdir -p " + shlex.quote(remote_dir)]
ssh_cmd = ["ssh"] + _ssh_common_args(env) + [target, 'mkdir -p "$HOME/' + remote_dir + '"']
proc = await asyncio.create_subprocess_exec(
*ssh_cmd, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL)
await asyncio.wait_for(proc.communicate(), timeout=30)
@ -244,8 +252,8 @@ async def migrate_work_dir(account_name: str, from_mode: str, to_mode: str, env:
remote_dir = env.get("remote_dir", "")
if not remote_dir:
return {"ok": False, "error": "缺少远程目录 remote_dir"}
await _remote_mkdir(env, _resolve_remote_dir(remote_dir))
target = _ssh_target(env) + ":" + _resolve_remote_dir(remote_dir)
await _remote_mkdir(env, _rel_remote_dir(remote_dir))
target = _ssh_target(env) + ":~/" + _rel_remote_dir(remote_dir)
cmd = ["rsync", "-az", "--delete", "-e",
"ssh " + " ".join(_ssh_common_args(env)),
local_dir.rstrip("/") + "/", target.rstrip("/") + "/"]
@ -254,7 +262,7 @@ async def migrate_work_dir(account_name: str, from_mode: str, to_mode: str, env:
remote_dir = env.get("remote_dir", "")
if not remote_dir:
return {"ok": False, "error": "缺少远程目录 remote_dir"}
src = _ssh_target(env) + ":" + _resolve_remote_dir(remote_dir)
src = _ssh_target(env) + ":~/" + _rel_remote_dir(remote_dir)
cmd = ["rsync", "-az", "--delete", "-e",
"ssh " + " ".join(_ssh_common_args(env)),
src.rstrip("/") + "/", local_dir.rstrip("/") + "/"]
@ -364,9 +372,9 @@ def _row_to_env_from_dict(data: dict) -> dict:
# 远程沙箱执行
# ═══════════════════════════════════════════════════════════
def _remote_bwrap_cmd(env: dict, deploy_dir: str, command: str, workdir: str = "") -> list:
"""构造远程主机的 bwrap 沙箱参数(不含 bwrap 前缀,由调用方拼接探测逻辑)。"""
parts = [
def _bwrap_static_args() -> list:
"""bwrap 沙箱固定参数(不含 --bind 目录和 command由调用方拼接)。"""
return [
"--unshare-user", "--unshare-pid", "--unshare-ipc", "--unshare-uts",
"--die-with-parent",
"--ro-bind", "/usr", "/usr",
@ -378,12 +386,7 @@ def _remote_bwrap_cmd(env: dict, deploy_dir: str, command: str, workdir: str = "
"--proc", "/proc",
"--dev", "/dev",
"--tmpfs", "/tmp",
"--bind", _resolve_remote_dir(deploy_dir), "/home",
"--chdir", "/home",
"--setenv", "HOME", "/home",
"--", "bash", "-c", command,
]
return parts
async def run_remote_sandbox(env: dict, deploy_dir: str, command: str,
@ -392,10 +395,13 @@ async def run_remote_sandbox(env: dict, deploy_dir: str, command: str,
ssh_args = _ssh_common_args(env)
target = _ssh_target(env)
bwrap_parts = _remote_bwrap_cmd(env, deploy_dir, command, workdir)
bwrap_args = " ".join(shlex.quote(p) for p in bwrap_parts)
# 远程 shell 内探测 bwrap 路径PATH 优先,回退 $HOME/bin/bwrap
inner = 'BW=$(command -v bwrap 2>/dev/null || echo "$HOME/bin/bwrap"); exec "$BW" ' + bwrap_args
# deploy_dir 是相对路径(已校验安全字符),用 $HOME 拼接让远程 shell 展开
rel = _rel_remote_dir(deploy_dir)
inner = ('BW=$(command -v bwrap 2>/dev/null || echo "$HOME/bin/bwrap"); '
'RD="$HOME/' + rel + '"; '
'exec "$BW" ' + " ".join(_bwrap_static_args()) + ' '
'--bind "$RD" /home --chdir /home --setenv HOME /home '
'-- bash -c ' + shlex.quote(command))
ssh_cmd = ["ssh"] + ssh_args + [target, inner]