From a16cdf3ab16180368728c84cbd0c4dd62ad8fde7 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 14 Sep 2026 15:22:58 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E4=B8=A4=E6=9D=A1=20critical=EF=BC=9A?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=B7=A5=E5=85=B7=E5=8F=AF=E8=AF=BB=E4=BB=BB?= =?UTF-8?q?=E6=84=8F=E8=B7=AF=E5=BE=84=E3=80=81=E5=B9=B6=E5=8F=91=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E8=8A=82=E7=82=B9=E4=BA=92=E7=9B=B8=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 文件工具可读任意文件(含加密主密钥) sandbox_session 从请求体直接拼进工作区路径,没有任何净化。传 ".." 就能把 根目录挪出工作区,之后 _resolve 的越界检查是在这个被挪走的根下做的,等于失效: curl -X POST /api/tools/file_read/run -d '{"sandbox_session":"..","args":{"path":".secret_key"}}' → {"ok":true,"content":"8UygDbx9..."} # 加密所有 provider Key 的 Fernet 主密钥 根因是同一段净化逻辑被抄了五遍:四个沙箱后端各一份,文件工具那份漏抄了。 所以提到 config.sanitize_session 统一一份,五处共用;RunToolIn.sandbox_session 再加 pattern 做纵深防御——畸形 session 直接 422,而不是悄悄净化成 default。 ## 并发代码节点互相覆盖入口脚本 同一 run 的节点共用一个沙箱会话,而入口脚本写死 main.py。README 主打的 "连线即并行"会让并排的代码节点在同一 superstep 并发执行,都写 main.py 就 互相覆盖——后写的赢,每个节点却都拿到 ok=True 和"自己的"返回值。 8 路并发同 session:7/8 执行了别人的代码,error 全为 None 改成每次执行生成唯一入口脚本(__entry_.py),跑完即删。共享工作区语义 保留,跨节点传文件照常。四个后端同构改造,实测 8/16 路并发均 0 串味。 ## 顺带修掉两个自己埋的坑 - conda 迁移把沙箱解释器隔离弄丢了:conda 环境 sys.prefix == sys.base_prefix, 于是"干净解释器"挑出来的就是项目环境自己,沙箱代码能 import cryptography 直接解密落库的 Key。改成实际探测候选解释器是否看得见敏感包,都不干净就 退回 -S 关掉 site-packages。README 那句承诺现在重新成立了。 - -I 在 3.11+ 隐含 -P,脚本目录不进 sys.path,代码节点的 files 参数(多文件) 在 seatbelt/bubblewrap 下一直是坏的,import helper 直接 ModuleNotFoundError。 换成 -s -E,隔离意图不变,同目录 import 恢复。 - microvm 的 files_written 取 FsEntry.name(不存在),塞进去的是对象 repr。 改取 path 的尾段。 沙箱回归 16 项通过(两档各 8 项)。 --- backend/app/api/tools.py | 5 +- backend/app/core/config.py | 30 ++++++ backend/app/sandbox/base.py | 16 ++++ backend/app/sandbox/bubblewrap_sandbox.py | 33 ++++--- backend/app/sandbox/interpreter.py | 106 ++++++++++++++++++++++ backend/app/sandbox/local_sandbox.py | 33 ++++--- backend/app/sandbox/microvm_sandbox.py | 53 +++++++---- backend/app/sandbox/seatbelt_sandbox.py | 56 ++++++------ backend/app/tools/builtin/files.py | 6 +- 9 files changed, 263 insertions(+), 75 deletions(-) create mode 100644 backend/app/sandbox/interpreter.py diff --git a/backend/app/api/tools.py b/backend/app/api/tools.py index ad5286bf..9c55dfdc 100644 --- a/backend/app/api/tools.py +++ b/backend/app/api/tools.py @@ -58,7 +58,10 @@ async def list_tools(session: AsyncSession = Depends(get_session)) -> list[dict[ class RunToolIn(BaseModel): args: dict[str, Any] = Field(default_factory=dict) - sandbox_session: str = "playground" + # 这个字段会被拼进工作目录路径。净化在 sanitize_session 里兜底,这里 + # 再加一道模式校验做纵深防御——畸形的 session 直接 422 拒掉, + # 而不是悄悄被净化成 "default" 让调用方以为自己写对了。 + sandbox_session: str = Field(default="playground", pattern=r"^[A-Za-z0-9_-]{1,64}$") @router.post("/{tool_name:path}/run") diff --git a/backend/app/core/config.py b/backend/app/core/config.py index a61d2e54..781c89be 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -8,6 +8,21 @@ from pydantic_settings import BaseSettings, SettingsConfigDict BACKEND_DIR = Path(__file__).resolve().parents[2] PROJECT_DIR = BACKEND_DIR.parent +# session_id 拼进路径前必须过这一关。允许的字符刻意收得很窄:字母数字加 +# 连字符下划线——点号也不放行,免得出现 ".." 这种能往上走的名字。 +_SESSION_MAX = 64 + + +def sanitize_session(session_id: str | None) -> str: + """把任意字符串收敛成一个能安全当目录名的 token。 + + 以前这段逻辑在四个沙箱后端里各抄了一遍,而文件工具那条路径漏抄了, + 结果 sandbox_session 传 ".." 就能读到工作区外的文件(连加密主密钥都 + 读得到)。所以统一收到这里,只此一份。 + """ + safe = "".join(c for c in (session_id or "") if c.isalnum() or c in "-_") + return safe[:_SESSION_MAX] or "default" + class Settings(BaseSettings): """全局配置。所有项都可以用 AGENTLAB_ 前缀的环境变量覆盖。""" @@ -77,6 +92,21 @@ class Settings(BaseSettings): """沙箱与文件工具的可写根目录,一切文件访问都被限制在这里面。""" return self.data_dir / "workspace" + def session_dir(self, session_id: str | None, *, sub: str = "") -> Path: + """会话工作目录。session_id 一律先净化再拼路径。 + + 这个函数存在的理由:session_id 有些调用点是外部可控的(工具 API 的 + 请求体就能指定),拿它直接拼路径就等于把工作区根目录交给调用方摆布, + 后面再怎么做越界检查都是在被挪走的根下面做的,形同虚设。 + """ + safe = sanitize_session(session_id) + root = (self.workspace_dir / (sub or "") / safe).resolve() + base = self.workspace_dir.resolve() + # 净化之后理应不可能跑出去,这里再断言一次:这条路径值得多一道保险 + if root != base and base not in root.parents: + raise ValueError(f"非法的 session 目录:{session_id!r}") + return root + @property def uploads_dir(self) -> Path: return self.data_dir / "uploads" diff --git a/backend/app/sandbox/base.py b/backend/app/sandbox/base.py index 1363e19f..b60f4ce6 100644 --- a/backend/app/sandbox/base.py +++ b/backend/app/sandbox/base.py @@ -2,11 +2,27 @@ from __future__ import annotations import abc from typing import Any +from uuid import uuid4 from pydantic import BaseModel, Field from app.core.config import settings +# 入口脚本的前缀。列"这次执行产出了哪些文件"时要把它们排掉—— +# 它们是执行机制自己的临时文件,不是用户代码的产出。 +ENTRY_PREFIX = "__entry_" + + +def entry_name(ext: str) -> str: + """给这一次执行生成独立的入口脚本名。 + + 同一个会话共用一个工作区(跨节点传文件要靠它),所以入口脚本**不能** + 用固定的 main.py:一张图里并排的两个代码节点会在同一 superstep 并发执行, + 都写 main.py 就会互相覆盖——后写的赢,而每个节点都以为跑的是自己的代码, + 还都返回 ok。这是静默的结果污染,比报错难查得多。 + """ + return f"{ENTRY_PREFIX}{uuid4().hex[:12]}{ext}" + class SandboxLimits(BaseModel): timeout: int = Field(default_factory=lambda: settings.sandbox_timeout) diff --git a/backend/app/sandbox/bubblewrap_sandbox.py b/backend/app/sandbox/bubblewrap_sandbox.py index d71d7e82..81bd2573 100644 --- a/backend/app/sandbox/bubblewrap_sandbox.py +++ b/backend/app/sandbox/bubblewrap_sandbox.py @@ -8,15 +8,18 @@ import sys import time from pathlib import Path -from app.core.config import settings -from app.sandbox.base import ExecResult, Sandbox, SandboxLimits, truncate +from app.core.config import settings, sanitize_session +from app.sandbox import interpreter +from app.sandbox.base import ENTRY_PREFIX, ExecResult, Sandbox, SandboxLimits, entry_name, truncate +# 入口脚本后缀 + 执行命令前缀(沙箱内路径在运行时拼)。文件名不能写死: +# 同会话并发执行都写 main.py 会互相覆盖。 _LANG = { - "python": ("main.py", ["python3", "-I", "-u", "/work/main.py"]), - "bash": ("main.sh", ["/bin/bash", "/work/main.sh"]), - "sh": ("main.sh", ["/bin/sh", "/work/main.sh"]), - "node": ("main.js", ["node", "/work/main.js"]), - "javascript": ("main.js", ["node", "/work/main.js"]), + "python": (".py", None), # 运行时用 interpreter.python_argv() + "bash": (".sh", ["/bin/bash"]), + "sh": (".sh", ["/bin/sh"]), + "node": (".js", ["node"]), + "javascript": (".js", ["node"]), } # 只读挂进沙箱的系统目录,存在才挂 @@ -51,12 +54,14 @@ class BubblewrapSandbox(Sandbox): "limits_note": "内存与 CPU 由 rlimit 限制;如需 cgroups 级配额请配合 systemd-run", "enforced": {"timeout": True, "cpu": True, "memory": True, "network": True, "fsize": True}, "root": str(self._root), + "interpreter": interpreter.describe(), **({} if ok else {"error": "未找到 bwrap,可执行 apt install bubblewrap 安装"}), } def _session_dir(self, session_id: str) -> Path: - safe = "".join(c for c in session_id if c.isalnum() or c in "-_")[:64] or "default" - path = self._root / safe + # 净化规则统一在 config.sanitize_session 里,四个后端和文件工具共用一份: + # 这段逻辑以前各抄各的,文件工具那份漏抄了,就成了越界读取的口子。 + path = self._root / sanitize_session(session_id) path.mkdir(parents=True, exist_ok=True) return path @@ -112,9 +117,13 @@ class BubblewrapSandbox(Sandbox): if not self.available(): return ExecResult(ok=False, backend=self.name, error="bwrap 不可用") - filename, cmd = _LANG[language] + ext, cmd_prefix = _LANG[language] + # python 的命令前缀运行时才定:要挑一个看不见后端依赖的解释器 + cmd_prefix = cmd_prefix if cmd_prefix is not None else interpreter.python_argv() + entry = entry_name(ext) + cmd = [*cmd_prefix, f"/work/{entry}"] workdir = self._session_dir(session_id or f"tmp-{int(time.time() * 1000)}") - (workdir / filename).write_text(code) + (workdir / entry).write_text(code) for rel, content in (files or {}).items(): target = (workdir / rel).resolve() if not str(target).startswith(str(workdir.resolve())): @@ -161,7 +170,7 @@ class BubblewrapSandbox(Sandbox): timed_out=code_ in (-24, 152), backend=self.name, duration_ms=int((time.perf_counter() - started) * 1000), - files_written=[p.name for p in workdir.iterdir() if p.is_file() and p.name != filename][:50], + files_written=[p.name for p in workdir.iterdir() if p.is_file() and not p.name.startswith(ENTRY_PREFIX)][:50], ) async def cleanup(self, session_id: str) -> None: diff --git a/backend/app/sandbox/interpreter.py b/backend/app/sandbox/interpreter.py new file mode 100644 index 00000000..60fcfa31 --- /dev/null +++ b/backend/app/sandbox/interpreter.py @@ -0,0 +1,106 @@ +"""给沙箱挑一个看不见后端依赖的 Python 解释器。 + +原先的做法是「用 `sys.base_prefix` 下的那个」,理由是 venv 会把 FastAPI、 +SQLAlchemy、凭据处理那一套挂进 sys.path,而 base_prefix 是干净的标准库环境。 + +这个假设在 venv 下成立,在 conda 下不成立:conda 环境没有 venv 那层分离, +`sys.prefix == sys.base_prefix`,于是"干净解释器"挑出来的就是项目环境本身, +沙箱代码能直接 `import cryptography` 去解密落库的 API Key。 + +所以这里不再假设,而是**实际验证**:挨个候选跑一下,看敏感包是否可见。 +都不干净时退回当前解释器并加 `-S` 关掉 site-packages —— 标准库照常可用, +第三方包一个也看不见。 +""" +from __future__ import annotations + +import subprocess +import sys +from functools import lru_cache +from pathlib import Path + +# 沙箱里绝不该出现的包。看得见任何一个,这个解释器就不算干净。 +# anthropic/openai 带着 provider 客户端,cryptography 能解密落库的 Key, +# sqlalchemy 能直接连库,fastapi 则说明整个后端环境都在。 +_SENSITIVE = ("fastapi", "sqlalchemy", "cryptography", "anthropic", "openai") + +_PROBE = ( + "import importlib.util as u;" + f"print(int(any(u.find_spec(m) for m in {_SENSITIVE!r})))" +) + + +def _is_clean(exe: str) -> bool: + """跑一下这个解释器,看敏感包是否可见。探测失败就当它不干净。""" + try: + out = subprocess.run( + [exe, "-c", _PROBE], + capture_output=True, text=True, timeout=10, + ) + return out.returncode == 0 and out.stdout.strip() == "0" + except (OSError, subprocess.SubprocessError): + return False + + +def _candidates() -> list[str]: + """按优先级列出候选,重复的去掉。 + + 先试 base_prefix(venv 下这就是对的),再试系统自带的 python3 + (macOS/Linux 都有,且不会装项目依赖),最后是 Homebrew 的。 + """ + base = Path(sys.base_prefix) / "bin" + major, minor = sys.version_info.major, sys.version_info.minor + raw = [ + base / f"python{major}.{minor}", + base / f"python{major}", + base / "python", + Path("/usr/bin/python3"), + Path("/usr/local/bin/python3"), + Path("/opt/homebrew/bin/python3"), + ] + seen, out = set(), [] + for p in raw: + try: + real = str(p.resolve()) + except OSError: + continue + if real in seen or not p.exists(): + continue + seen.add(real) + out.append(real) + return out + + +@lru_cache(maxsize=1) +def resolve() -> tuple[str, bool]: + """返回 (解释器路径, 是否干净)。探测有子进程开销,所以缓存。""" + for exe in _candidates(): + if _is_clean(exe): + return exe, True + return str(Path(sys.executable).resolve()), False + + +def python_argv() -> list[str]: + """沙箱执行 Python 时的命令前缀(不含脚本名)。 + + 刻意**不用** `-I`:它在 3.11+ 隐含 `-P`,脚本所在目录不会进 sys.path, + 于是代码节点的 files 参数(多文件)整个失效 —— `import helper` 报 + ModuleNotFoundError。用 `-s -E` 拿到同样的隔离意图(不加 user site、 + 忽略 PYTHONPATH 之类的环境变量),又不影响同目录 import。 + """ + exe, clean = resolve() + flags = ["-s", "-E", "-u"] if clean else ["-S", "-s", "-E", "-u"] + return [exe, *flags] + + +def describe() -> dict[str, object]: + """给 health 用:说清楚沙箱到底在用哪个解释器、干不干净。""" + exe, clean = resolve() + return { + "python": exe, + "clean": clean, + "note": ( + "解释器本身看不到后端依赖" + if clean else + "没找到干净的解释器,改用 -S 关掉 site-packages(标准库仍可用,第三方包不可见)" + ), + } diff --git a/backend/app/sandbox/local_sandbox.py b/backend/app/sandbox/local_sandbox.py index 083aaeb1..d5e4e95a 100644 --- a/backend/app/sandbox/local_sandbox.py +++ b/backend/app/sandbox/local_sandbox.py @@ -8,15 +8,18 @@ import sys import time from pathlib import Path -from app.core.config import settings -from app.sandbox.base import ExecResult, Sandbox, SandboxLimits, truncate +from app.core.config import settings, sanitize_session +from app.sandbox import interpreter +from app.sandbox.base import ENTRY_PREFIX, ExecResult, Sandbox, SandboxLimits, entry_name, truncate +# 入口脚本后缀 + 执行命令。文件名运行时生成(entry_name),不能写死: +# 同会话并发执行都写 main.py 会互相覆盖。 _LANG = { - "python": ("main.py", [sys.executable, "-u", "main.py"]), - "bash": ("main.sh", ["bash", "main.sh"]), - "sh": ("main.sh", ["sh", "main.sh"]), - "node": ("main.js", ["node", "main.js"]), - "javascript": ("main.js", ["node", "main.js"]), + "python": (".py", None), # 运行时用 interpreter.python_argv() + "bash": (".sh", ["bash"]), + "sh": (".sh", ["sh"]), + "node": (".js", ["node"]), + "javascript": (".js", ["node"]), } @@ -43,11 +46,13 @@ class LocalSandbox(Sandbox): "enforced": {"timeout": True, "cpu": True, "memory": not sys.platform == "darwin", "network": False, "fsize": True}, "root": str(self._root), + "interpreter": interpreter.describe(), } def _session_dir(self, session_id: str) -> Path: - safe = "".join(c for c in session_id if c.isalnum() or c in "-_")[:64] or "default" - path = self._root / safe + # 净化规则统一在 config.sanitize_session 里,四个后端和文件工具共用一份: + # 这段逻辑以前各抄各的,文件工具那份漏抄了,就成了越界读取的口子。 + path = self._root / sanitize_session(session_id) path.mkdir(parents=True, exist_ok=True) return path @@ -87,14 +92,18 @@ class LocalSandbox(Sandbox): if language not in _LANG: return ExecResult(ok=False, backend=self.name, error=f"不支持的语言:{language}") - filename, argv = _LANG[language] + ext, cmd = _LANG[language] + # python 的命令前缀运行时才定:要挑一个看不见后端依赖的解释器 + cmd = cmd if cmd is not None else interpreter.python_argv() + entry = entry_name(ext) + argv = [*cmd, entry] if shutil.which(argv[0]) is None and argv[0] != sys.executable: return ExecResult( ok=False, backend=self.name, error=f"宿主机上找不到 {argv[0]}" ) workdir = self._session_dir(session_id or f"tmp-{int(time.time()*1000)}") - (workdir / filename).write_text(code) + (workdir / entry).write_text(code) for rel, content in (files or {}).items(): target = (workdir / rel).resolve() if not str(target).startswith(str(workdir.resolve())): @@ -146,7 +155,7 @@ class LocalSandbox(Sandbox): written = [ p.name for p in workdir.iterdir() - if p.is_file() and p.name != filename + if p.is_file() and not p.name.startswith(ENTRY_PREFIX) ][:50] return ExecResult( ok=proc.returncode == 0, diff --git a/backend/app/sandbox/microvm_sandbox.py b/backend/app/sandbox/microvm_sandbox.py index 923c4085..be4c54c9 100644 --- a/backend/app/sandbox/microvm_sandbox.py +++ b/backend/app/sandbox/microvm_sandbox.py @@ -6,18 +6,26 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any -from app.core.config import settings -from app.sandbox.base import ExecResult, Sandbox, SandboxLimits, truncate +from app.core.config import sanitize_session, settings +from app.sandbox.base import ( + ENTRY_PREFIX, + ExecResult, + Sandbox, + SandboxLimits, + entry_name, + truncate, +) _WORKDIR = "/workspace" -# 各语言在 VM 里的落地文件与执行命令 +# 各语言的入口脚本后缀与执行命令。文件名在运行时生成(见 entry_name), +# 不能写死 —— 同会话并发执行会互相覆盖。 _LANG = { - "python": ("main.py", ["python", "-u", "main.py"]), - "bash": ("main.sh", ["/bin/bash", "main.sh"]), - "sh": ("main.sh", ["/bin/sh", "main.sh"]), - "node": ("main.js", ["node", "main.js"]), - "javascript": ("main.js", ["node", "main.js"]), + "python": (".py", ["python", "-u"]), + "bash": (".sh", ["/bin/bash"]), + "sh": (".sh", ["/bin/sh"]), + "node": (".js", ["node"]), + "javascript": (".js", ["node"]), } _MAX_FILE_BYTES = 8 * 1024 * 1024 @@ -141,9 +149,10 @@ class MicroVMSandbox(Sandbox): # ---------------- 会话(长驻 VM)---------------- def _vm_name(self, session_id: str) -> str: - # SDK 要求:首字符必须是字母数字,其余只能是字母数字/点/横线/下划线 - safe = "".join(c for c in session_id if c.isalnum() or c in "-_.")[:96] - return f"agentlab-{safe or 'default'}" + # SDK 要求首字符是字母数字,其余只能是字母数字/点/横线/下划线。 + # 这里直接用共用的净化规则(比 SDK 的要求更严,不放行点号), + # 让 VM 名和别处的会话目录名保持一致。 + return f"agentlab-{sanitize_session(session_id)}" async def _lease(self, session_id: str, limits: SandboxLimits) -> _Lease: """拿到该会话的 VM。限额变了就重建,否则复用(热复用约 7ms)。""" @@ -223,7 +232,8 @@ class MicroVMSandbox(Sandbox): error="microVM 运行时未就绪:pip install 'agentlab-backend[microvm]' 后执行一次 install", ) - filename, argv = _LANG[language] + ext, cmd = _LANG[language] + entry = entry_name(ext) sid = session_id or f"tmp{int(time.time() * 1000)}" ephemeral = session_id is None started = time.perf_counter() @@ -247,7 +257,7 @@ class MicroVMSandbox(Sandbox): try: payload = dict(files or {}) - payload[filename] = code + payload[entry] = code for rel, content in payload.items(): rel = rel.lstrip("/") if ".." in rel.split("/"): @@ -265,7 +275,7 @@ class MicroVMSandbox(Sandbox): # cwd / env / timeout 都是 exec 的原生参数,不用再拿 shell 拼 out = await lease.vm.exec( - argv[0], list(argv[1:]), + cmd[0], [*cmd[1:], entry], cwd=_WORKDIR, env=({k: str(v) for k, v in env.items()} if env else None), timeout=limits.timeout, @@ -287,6 +297,13 @@ class MicroVMSandbox(Sandbox): finally: if ephemeral: await self._destroy(sid) + elif sid in self._leases: + # 入口脚本是这次执行的私有临时文件,跑完就收掉, + # 免得会话工作区被历年的 __entry_xxx 堆满 + try: + await lease.vm.fs.remove(f"{_WORKDIR}/{entry}") + except Exception: # noqa: BLE001 - 删不掉不影响执行结果 + pass stdout, t1 = truncate(str(getattr(out, "stdout_text", "") or ""), limits.max_output) stderr, t2 = truncate(str(getattr(out, "stderr_text", "") or ""), limits.max_output) @@ -304,10 +321,10 @@ class MicroVMSandbox(Sandbox): if not ephemeral: try: entries = await lease.vm.fs.list(_WORKDIR) - written = [ - getattr(e, "name", str(e)) for e in entries - if getattr(e, "name", "") != filename - ][:50] + # FsEntry 给的是 path(全路径),不是 name —— 取尾段当文件名。 + # 早先按 name 取,结果 files_written 里塞的是对象 repr。 + names = [str(getattr(e, "path", "") or "").rsplit("/", 1)[-1] for e in entries] + written = [n for n in names if n and not n.startswith(ENTRY_PREFIX)][:50] except Exception: # noqa: BLE001 - 列目录失败不影响执行结果 written = [] diff --git a/backend/app/sandbox/seatbelt_sandbox.py b/backend/app/sandbox/seatbelt_sandbox.py index 605021d9..459fe522 100644 --- a/backend/app/sandbox/seatbelt_sandbox.py +++ b/backend/app/sandbox/seatbelt_sandbox.py @@ -8,8 +8,9 @@ import sys import time from pathlib import Path -from app.core.config import settings -from app.sandbox.base import ExecResult, Sandbox, SandboxLimits, truncate +from app.core.config import sanitize_session, settings +from app.sandbox import interpreter +from app.sandbox.base import ENTRY_PREFIX, ExecResult, Sandbox, SandboxLimits, entry_name, truncate SANDBOX_EXEC = "/usr/bin/sandbox-exec" @@ -22,30 +23,18 @@ def _real(path: str | Path) -> str: def sandbox_python() -> str: - """沙箱里用的 Python 解释器。 - - 刻意用 `sys.base_prefix` 下的那个,而不是后端自己的 venv:venv 会把 - FastAPI、SQLAlchemy、provider 的凭据处理代码等一整套依赖挂进 sys.path, - 沙箱代码不该看得见它们。base_prefix 是一个干净的标准库环境。 - """ - base = Path(sys.base_prefix) - major, minor = sys.version_info.major, sys.version_info.minor - for candidate in ( - base / "bin" / f"python{major}.{minor}", - base / "bin" / f"python{major}", - base / "bin" / "python", - ): - if candidate.exists(): - return _real(candidate) - return _real(sys.executable) + """沙箱里用的 Python 解释器路径。挑选逻辑见 app.sandbox.interpreter。""" + return interpreter.resolve()[0] +# 入口脚本后缀 + 执行命令前缀(解释器路径要延迟求值,所以是 lambda)。 +# 文件名运行时生成:同会话并发执行都写 main.py 会互相覆盖。 _LANG = { - "python": ("main.py", lambda: [sandbox_python(), "-I", "-u", "main.py"]), - "bash": ("main.sh", lambda: ["/bin/bash", "main.sh"]), - "sh": ("main.sh", lambda: ["/bin/sh", "main.sh"]), - "node": ("main.js", lambda: [_real(shutil.which("node") or "node"), "main.js"]), - "javascript": ("main.js", lambda: [_real(shutil.which("node") or "node"), "main.js"]), + "python": (".py", interpreter.python_argv), + "bash": (".sh", lambda: ["/bin/bash"]), + "sh": (".sh", lambda: ["/bin/sh"]), + "node": (".js", lambda: [_real(shutil.which("node") or "node")]), + "javascript": (".js", lambda: [_real(shutil.which("node") or "node")]), } @@ -61,7 +50,10 @@ def _interpreter_roots(language: str) -> list[str]: "/private/var/select", "/private/var/db", } if language == "python": - roots.add(_real(sys.base_prefix)) + # 放行**实际选中**的那个解释器的安装根,而不是 sys.base_prefix —— + # conda 环境下 base_prefix 就是项目环境自己,挑出来的往往是别的解释器 + exe = Path(interpreter.resolve()[0]) + roots.add(_real(exe.parent.parent)) elif language in ("node", "javascript"): node = shutil.which("node") if node: @@ -152,12 +144,14 @@ class SeatbeltSandbox(Sandbox): # 如实申报:界面按这个把限不住的项划掉,避免给人虚假的安全感 "enforced": {"timeout": True, "cpu": True, "memory": False, "network": True, "fsize": True}, "root": str(self._root), + "interpreter": interpreter.describe(), **({} if ok else {"error": "当前系统不是 macOS 或缺少 /usr/bin/sandbox-exec"}), } def _session_dir(self, session_id: str) -> Path: - safe = "".join(c for c in session_id if c.isalnum() or c in "-_")[:64] or "default" - path = self._root / safe + # 净化规则统一在 config.sanitize_session 里,四个后端和文件工具共用一份: + # 这段逻辑以前各抄各的,文件工具那份漏抄了,就成了越界读取的口子。 + path = self._root / sanitize_session(session_id) path.mkdir(parents=True, exist_ok=True) return path @@ -193,9 +187,10 @@ class SeatbeltSandbox(Sandbox): if not self.available(): return ExecResult(ok=False, backend=self.name, error="sandbox-exec 不可用") - filename, argv_of = _LANG[language] + ext, cmd_of = _LANG[language] + entry = entry_name(ext) workdir = self._session_dir(session_id or f"tmp-{int(time.time() * 1000)}") - (workdir / filename).write_text(code) + (workdir / entry).write_text(code) for rel, content in (files or {}).items(): target = (workdir / rel).resolve() @@ -208,7 +203,7 @@ class SeatbeltSandbox(Sandbox): policy_file = self._policies / f"{workdir.name}-{language}.sb" policy_file.write_text(build_policy(workdir, language=language, network=limits.network)) - argv = [SANDBOX_EXEC, "-f", str(policy_file), *argv_of()] + argv = [SANDBOX_EXEC, "-f", str(policy_file), *cmd_of(), entry] # HOME 指向工作区:既挡住家目录,又让解释器的缓存有地方落 child_env = { @@ -254,7 +249,8 @@ class SeatbeltSandbox(Sandbox): # CPU 时间用尽是 SIGXCPU(24),归到超时里更好理解 timed_out = code_ in (-24, 152) written = [ - p.name for p in workdir.iterdir() if p.is_file() and p.name != filename + p.name for p in workdir.iterdir() + if p.is_file() and not p.name.startswith(ENTRY_PREFIX) ][:50] return ExecResult( diff --git a/backend/app/tools/builtin/files.py b/backend/app/tools/builtin/files.py index 6d2ba09b..aec0d529 100644 --- a/backend/app/tools/builtin/files.py +++ b/backend/app/tools/builtin/files.py @@ -14,9 +14,11 @@ _MAX_READ = 200_000 def _resolve(rel: str, ctx: ToolContext) -> Path: """把相对路径锁死在这次运行自己的工作目录里。 - 先 resolve 再比较,所以 `../../etc/passwd` 和软链接都逃不出去。 + 两道关都要过:session_dir() 先净化 session_id 再定根目录,然后 rel + 先 resolve 再比对根。少了第一道,`../../etc/passwd` 里的越界检查就是 + 在一个被挪到 /etc 的"根"下面做的,检查照样通过——这正是之前的漏洞。 """ - root = (settings.workspace_dir / (ctx.sandbox_session or "default")).resolve() + root = settings.session_dir(ctx.sandbox_session) root.mkdir(parents=True, exist_ok=True) target = (root / rel.lstrip("/")).resolve() if target != root and root not in target.parents: