|
| 1 | +# Python Code Sandbox 隔离执行方案 |
| 2 | + |
| 3 | +## 背景 |
| 4 | + |
| 5 | +改造前,`projects/code-sandbox` 的 Python 执行路径使用长驻 `PythonProcessPool` |
| 6 | +和 `worker.py`。同一个 Python 进程会多次执行用户代码,主要依赖 |
| 7 | +`__import__` 白名单、受限 `open`、AST 检查、替换 builtin 等进程内限制。 |
| 8 | + |
| 9 | +GHSA-5jmh-5f2m-89jg 证明该模型存在结构性缺陷:Python 反射链可以绕过 |
| 10 | +进程内 denylist,并最终获得容器内 OS 命令执行能力。继续补 AST 或字符串 |
| 11 | +拦截只能覆盖已知 payload,不能作为多租户安全边界。 |
| 12 | + |
| 13 | +当前方案已经落地为 Python isolated one-shot warm pool: |
| 14 | + |
| 15 | +- 旧 `worker.py` 和 `PythonProcessPool` 已删除; |
| 16 | +- `/sandbox/python` 统一使用 `PythonIsolatedRunner`; |
| 17 | +- Python 进程最多执行一次用户代码,执行后销毁; |
| 18 | +- Linux/Docker 环境固定启用 `chroot`、`no_new_privs`、`seccomp`、`setgid/setuid`; |
| 19 | +- 网络请求统一走父进程 HTTP 代理,不允许 Python 子进程直接网络 syscall。 |
| 20 | + |
| 21 | +## 目标 |
| 22 | + |
| 23 | +- 保持 `/sandbox/python` API 兼容:成功返回 `{ success, data: { codeReturn, log } }`,失败返回 `{ success:false, message }`。 |
| 24 | +- 兼容历史 Python Code 写法:`main()`、`main(variables)`、`main(a,b)`、全局变量注入、`print` 收集、内置 helper。 |
| 25 | +- 多租户安全边界不依赖用户代码所在 Python 进程内的软限制。 |
| 26 | +- 保留已有安全能力:模块白名单、文件限制、SSRF 防护、请求次数/大小限制、超时、输出限制、RSS 监控。 |
| 27 | +- 降低冷启动延迟,但不复用执行过用户代码的 Python 解释器。 |
| 28 | + |
| 29 | +## 非目标 |
| 30 | + |
| 31 | +- 不移除 JS `ProcessPool`。 |
| 32 | +- 不提供 Python pool 回滚模式。 |
| 33 | +- 不把 AST 检查作为主要安全边界;它只作为纵深防御和兼容性辅助。 |
| 34 | +- 不声称完整容器逃逸防护;OS 级隔离只约束当前 Python 执行进程的 syscall、根目录和权限。 |
| 35 | + |
| 36 | +## 总体架构 |
| 37 | + |
| 38 | +```text |
| 39 | +POST /sandbox/python |
| 40 | + -> queueIdLimiter.run(queueId, ...) |
| 41 | + -> PythonIsolatedRunner.execute({ code, variables }) |
| 42 | + -> 获取干净的 one-shot 预热 Python 进程 |
| 43 | + -> 无空闲进程时按需 spawn |
| 44 | + -> 预热进程已完成 chroot + no_new_privs + seccomp + setgid/setuid |
| 45 | + -> bootstrap 注入 helper、variables、受限 builtins |
| 46 | + -> exec 用户代码并调用 main |
| 47 | + -> stdout 输出 JSON line result/http_request |
| 48 | + -> 父进程解析结果、处理 HTTP 代理、监控超时/RSS/输出大小 |
| 49 | + -> 该 Python 进程销毁,不归还池中 |
| 50 | + -> 异步补充新的干净预热进程 |
| 51 | +``` |
| 52 | + |
| 53 | +`/sandbox/modules` 仍返回 `env.SANDBOX_PYTHON_ALLOWED_MODULES`,表示 Python |
| 54 | +用户可直接 import 的白名单模块,不暴露底层 runner 类型。 |
| 55 | + |
| 56 | +## 核心模块 |
| 57 | + |
| 58 | +### `src/isolated/python-isolated-runner.ts` |
| 59 | + |
| 60 | +`PythonIsolatedRunner` 负责父进程侧调度和资源控制: |
| 61 | + |
| 62 | +- 维护 one-shot warm pool,默认预热 `SANDBOX_POOL_SIZE` 个空闲 Python 进程; |
| 63 | +- 预热进程只执行 `init` 协议,不执行用户代码; |
| 64 | +- `execute()` 优先使用空闲预热进程,没有空闲进程时按需创建; |
| 65 | +- 每个 Python 进程最多接受一条用户任务,任务结束后销毁; |
| 66 | +- 执行完成后异步补充新的干净预热进程; |
| 67 | +- 使用 `Semaphore` 控制 Python 任务最大并发,复用 `SANDBOX_POOL_SIZE`; |
| 68 | +- 监控超时、输出大小和进程树 RSS; |
| 69 | +- 清理整个进程树,避免子进程残留; |
| 70 | +- 处理 Python 发起的 `http_request` IPC,并由父进程统一执行网络请求。 |
| 71 | + |
| 72 | +### `src/isolated/python-bootstrap.py` |
| 73 | + |
| 74 | +`python-bootstrap.py` 是 Python 子进程入口: |
| 75 | + |
| 76 | +- 支持两种协议: |
| 77 | + - 兼容模式:直接读取一条 task JSON 并执行; |
| 78 | + - 预热模式:先读取 `type:"init"`,完成 native 隔离后输出 `type:"ready"`,再等待一条 task JSON; |
| 79 | +- 初始化 Python helper:`SystemHelper`、`system_helper`、`http_request`、`count_token`、`str_to_base64`、`create_hmac`、`delay`; |
| 80 | +- 注入 `variables` 和历史全局变量写法; |
| 81 | +- 安装受限 builtins、import 白名单、受限 `open`、危险属性拦截、audit hook; |
| 82 | +- 捕获 `print` 到内存 log,避免污染 stdout JSON line 协议; |
| 83 | +- 执行 `main()` / `main(variables)` / `main(a,b)`; |
| 84 | +- 通过 stdout 输出 `result` 或 `http_request` JSON line。 |
| 85 | + |
| 86 | +### `native/python-sandbox` |
| 87 | + |
| 88 | +Go shared library `fastgpt_python_sandbox.so` 负责 Linux native 隔离: |
| 89 | + |
| 90 | +- `chroot` 到固定 Python sandbox root; |
| 91 | +- `chdir("/")`; |
| 92 | +- `setgroups([])`; |
| 93 | +- `setgid(65537)` / `setuid(65537)`; |
| 94 | +- `PR_SET_NO_NEW_PRIVS`; |
| 95 | +- 安装 seccomp filter; |
| 96 | +- 危险 syscall 默认拒绝,`execve/execveat`、`ptrace`、`mount` 等不能落地。 |
| 97 | + |
| 98 | +## 配置 |
| 99 | + |
| 100 | +Python 隔离相关配置已经收敛为内部安全默认,不提供运行时环境变量关闭或改弱: |
| 101 | + |
| 102 | +| 配置 | 当前值 | 说明 | |
| 103 | +| --- | --- | --- | |
| 104 | +| Python 最大并发 | `SANDBOX_POOL_SIZE` | 复用现有进程池大小配置 | |
| 105 | +| 预热空闲进程数 | `SANDBOX_POOL_SIZE` | 与 Python 最大并发保持一致 | |
| 106 | +| 直接网络 syscall | `false` | 统一走父进程 `http_request` 代理 | |
| 107 | +| chroot 根目录 | `/tmp/fastgpt-python-sandbox` | Docker 构建阶段准备 | |
| 108 | +| 用户代码 uid/gid | `65537:65537` | native 初始化后降权 | |
| 109 | +| native seccomp/chroot/setuid | Linux 固定开启 | 缺少 native 库或 chroot root 时 fail-closed | |
| 110 | + |
| 111 | +保留的 Python 业务配置: |
| 112 | + |
| 113 | +| 变量 | 说明 | |
| 114 | +| --- | --- | |
| 115 | +| `SANDBOX_PYTHON_ALLOWED_MODULES` | 用户代码可直接 import 的 Python 模块白名单 | |
| 116 | +| `SANDBOX_MAX_TIMEOUT` | 单次执行最大超时 | |
| 117 | +| `SANDBOX_MAX_MEMORY_MB` | 子进程树 RSS 软限制 | |
| 118 | +| `SANDBOX_MAX_OUTPUT_MB` | stdout/log 输出上限 | |
| 119 | +| `SANDBOX_REQUEST_*` | 父进程 HTTP 代理的次数、超时、请求体和响应体限制 | |
| 120 | + |
| 121 | +## 网络代理 |
| 122 | + |
| 123 | +Python 子进程不允许直接使用网络 syscall。用户代码如需请求外部网络,必须调用: |
| 124 | + |
| 125 | +```python |
| 126 | +http_request(url, method='GET', headers=None, body=None, timeout=None) |
| 127 | +# 或 |
| 128 | +system_helper.http_request(...) |
| 129 | +SystemHelper.httpRequest(...) |
| 130 | +``` |
| 131 | + |
| 132 | +Python bootstrap 向父进程写出: |
| 133 | + |
| 134 | +```json |
| 135 | +{ |
| 136 | + "type": "http_request", |
| 137 | + "id": "http-1", |
| 138 | + "payload": { |
| 139 | + "url": "https://example.com", |
| 140 | + "method": "GET", |
| 141 | + "headers": {}, |
| 142 | + "body": null, |
| 143 | + "timeout": null |
| 144 | + } |
| 145 | +} |
| 146 | +``` |
| 147 | + |
| 148 | +父进程执行: |
| 149 | + |
| 150 | +- URL 协议限制; |
| 151 | +- SSRF / 内网地址检查; |
| 152 | +- DNS pinning; |
| 153 | +- 请求次数限制; |
| 154 | +- 请求体大小限制; |
| 155 | +- 响应体大小限制; |
| 156 | +- 超时控制。 |
| 157 | + |
| 158 | +父进程再通过 stdin 返回: |
| 159 | + |
| 160 | +```json |
| 161 | +{ |
| 162 | + "type": "http_response", |
| 163 | + "id": "http-1", |
| 164 | + "success": true, |
| 165 | + "payload": {} |
| 166 | +} |
| 167 | +``` |
| 168 | + |
| 169 | +每个 Python 进程只执行一次任务,因此请求次数计数天然按单次执行归零。 |
| 170 | + |
| 171 | +## 安全边界 |
| 172 | + |
| 173 | +### 语言层边界 |
| 174 | + |
| 175 | +语言层限制用于减少误用和拦截已知危险能力,但不是主安全边界: |
| 176 | + |
| 177 | +- `__import__` 白名单; |
| 178 | +- `open` 受限; |
| 179 | +- `eval` / `exec` / `compile` / `globals` / `locals` / `vars` / `dir` 等禁用; |
| 180 | +- `__class__`、`__base__`、`__subclasses__`、`__globals__` 等危险属性拦截; |
| 181 | +- `object` builtin 替换; |
| 182 | +- audit hook 拦截 `os.system`、`subprocess`、`socket`、`ctypes` 等事件; |
| 183 | +- AST 检查作为纵深防御。 |
| 184 | + |
| 185 | +### OS 层边界 |
| 186 | + |
| 187 | +OS 层是多租户核心安全边界: |
| 188 | + |
| 189 | +- 每个 Python 子进程最多执行一次用户代码; |
| 190 | +- 执行用户代码前已经 chroot、降权、安装 seccomp; |
| 191 | +- 执行完成后整个进程树销毁; |
| 192 | +- 不复用执行过用户代码的解释器; |
| 193 | +- seccomp 不允许命令执行和高危系统调用; |
| 194 | +- chroot 只包含 Python 运行所需 stdlib、site-packages、动态库、证书、DNS 配置和 sandbox runtime 文件。 |
| 195 | + |
| 196 | +### 资源边界 |
| 197 | + |
| 198 | +- 父进程定时采样子进程树 RSS,超过 `SANDBOX_MAX_MEMORY_MB + RUNTIME_MEMORY_OVERHEAD_MB` 后杀进程树; |
| 199 | +- 父进程控制总超时; |
| 200 | +- stdout JSON line 和收集到的 log 受 `SANDBOX_MAX_OUTPUT_MB` 限制; |
| 201 | +- 进程树清理由 `killProcessTree()` 处理,优先杀 descendant 和 process group。 |
| 202 | + |
| 203 | +## Docker 和构建 |
| 204 | + |
| 205 | +Docker 镜像使用 Debian bookworm/glibc。Alpine/musl 下 Go c-shared `.so` |
| 206 | +存在兼容风险,不能作为当前 Python native isolation 运行基线。 |
| 207 | + |
| 208 | +构建流程: |
| 209 | + |
| 210 | +- `SANDBOX_BUILD_NATIVE_PYTHON=true pnpm build` 构建 Go shared library; |
| 211 | +- `build.sh` 复制 `python-bootstrap.py` 和 `fastgpt_python_sandbox.so` 到 `dist`; |
| 212 | +- Docker runner 阶段安装 Python、numpy、pandas、matplotlib 和 native 依赖; |
| 213 | +- Docker 构建阶段准备 `/tmp/fastgpt-python-sandbox` chroot root; |
| 214 | +- code-sandbox 主进程保留 root,以便 Python 子进程在 native 初始化阶段执行 chroot/setuid;用户代码进程会降权到 sandbox 用户。 |
| 215 | + |
| 216 | +## 测试覆盖 |
| 217 | + |
| 218 | +### 兼容性 |
| 219 | + |
| 220 | +- `main()`; |
| 221 | +- `main(variables)`; |
| 222 | +- `main(a,b)`; |
| 223 | +- 全局变量注入; |
| 224 | +- `print` log; |
| 225 | +- 历史 helper; |
| 226 | +- 旧 Python Code 节点写法。 |
| 227 | + |
| 228 | +### 安全 |
| 229 | + |
| 230 | +- GHSA-5jmh-5f2m-89jg 相关 `__base__` / `__subclasses__` 逃逸; |
| 231 | +- 动态 `getattr`; |
| 232 | +- import `os` / `sys` / `subprocess`; |
| 233 | +- 文件系统访问; |
| 234 | +- `os.system` / `subprocess` / `socket` / `ctypes`; |
| 235 | +- 直接网络能力; |
| 236 | +- 父进程 HTTP 代理 SSRF 防护; |
| 237 | +- 请求大小、响应大小、请求次数、超时。 |
| 238 | + |
| 239 | +### 生命周期和资源 |
| 240 | + |
| 241 | +- init 后存在干净预热进程; |
| 242 | +- 预热进程执行一次后销毁,不归还池中; |
| 243 | +- 执行后自动补充新的干净预热进程; |
| 244 | +- 并发超过上限时排队; |
| 245 | +- 超时后可恢复; |
| 246 | +- 内存超限后可恢复; |
| 247 | +- shutdown 清理 running / idle / warming 子进程。 |
| 248 | + |
| 249 | +### Docker/Linux |
| 250 | + |
| 251 | +- native `.so` 加载; |
| 252 | +- setuid/setgid 降权; |
| 253 | +- chroot 生效; |
| 254 | +- seccomp 阻断命令执行; |
| 255 | +- numpy/pandas/matplotlib 在 chroot/seccomp 下可用; |
| 256 | +- Docker 包可用性测试覆盖 Python 和 JS 白名单包。 |
| 257 | + |
| 258 | +## 性能和资源 |
| 259 | + |
| 260 | +one-shot warm pool 的收益主要在低并发和空闲命中场景: |
| 261 | + |
| 262 | +- 空闲预热进程命中时,省去 Python spawn/bootstrap/native init 的一部分延迟; |
| 263 | +- 高并发短任务下,预热池可以覆盖 `SANDBOX_POOL_SIZE` 以内的首批请求,但每个进程执行一次后仍需销毁并补充,因此稳态吞吐仍不会接近旧长驻 pool; |
| 264 | +- 预热进程不提前 import pandas/numpy,避免 idle 内存过高; |
| 265 | +- 当前 Docker/seccomp 下,单个 idle Python bootstrap 进程 RSS 约 18.7MB; |
| 266 | +- `SANDBOX_POOL_SIZE=20` 时,Python idle 进程 RSS 粗略约 374MB;实际容器 RSS 还包含 JS worker、Node 主进程、共享页和系统库统计口径。 |
| 267 | + |
| 268 | +安全优先级高于短任务吞吐。如果未来需要继续优化,应优先评估: |
| 269 | + |
| 270 | +- 是否为重包场景做专门的 package page-cache 预热,而不是让 idle 进程提前 import; |
| 271 | +- 是否引入 clean forkserver。forkserver 父进程必须永不执行用户代码,且子进程在执行前完成 fd 清理、chroot、setuid 和 seccomp。 |
| 272 | + |
| 273 | +## 验收标准 |
| 274 | + |
| 275 | +- 旧 Python pool/worker 代码删除; |
| 276 | +- `/sandbox/python` 只使用 `PythonIsolatedRunner`; |
| 277 | +- Linux 缺少 native `.so` 或 chroot root 时 fail-closed; |
| 278 | +- Python 子进程直接网络 syscall 固定关闭; |
| 279 | +- 执行过用户代码的 Python 进程不会复用; |
| 280 | +- 原有 Python 兼容用例通过; |
| 281 | +- 原有安全边界测试迁移并通过; |
| 282 | +- Docker/seccomp 下 Python 包可用性和 OS 隔离测试通过。 |
| 283 | + |
| 284 | +## 当前验证命令 |
| 285 | + |
| 286 | +```bash |
| 287 | +pnpm --filter @fastgpt/code-sandbox exec tsc --noEmit |
| 288 | +SANDBOX_MAX_MEMORY_MB=256 pnpm --filter @fastgpt/code-sandbox exec vitest run --coverage.enabled=false |
| 289 | +pnpm --filter @fastgpt/code-sandbox build |
| 290 | +docker build --build-arg proxy=1 -f projects/code-sandbox/Dockerfile -t fastgpt-code-sandbox-warm-pool . |
| 291 | +``` |
0 commit comments