Skip to content

Commit 0c1840c

Browse files
authored
[codex] secure code sandbox secret isolation (#7173)
* redirect check * secure python sandbox isolation * fix sandbox http proxy timeout handling * limit python native package threads * fix sandbox allowed module compatibility * Refine LLM context compression * chore: harden code sandbox security * fix issue * fix: allow python sandbox task directories * fix: i18n * compress * fix: stabilize matplotlib sandbox cache dirs
1 parent b2ab4b5 commit 0c1840c

99 files changed

Lines changed: 7766 additions & 2351 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
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

Comments
 (0)