Back to Fastgpt

Python Code Sandbox 隔离执行方案

.agents/design/code-sandbox/python-isolated-runner.md

4.15.011.3 KB
Original Source

Python Code Sandbox 隔离执行方案

背景

改造前,projects/code-sandbox 的 Python 执行路径使用长驻 PythonProcessPoolworker.py。同一个 Python 进程会多次执行用户代码,主要依赖 __import__ 白名单、受限 open、AST 检查、替换 builtin 等进程内限制。

GHSA-5jmh-5f2m-89jg 证明该模型存在结构性缺陷:Python 反射链可以绕过 进程内 denylist,并最终获得容器内 OS 命令执行能力。继续补 AST 或字符串 拦截只能覆盖已知 payload,不能作为多租户安全边界。

当前方案已经落地为 Python isolated one-shot warm pool:

  • worker.pyPythonProcessPool 已删除;
  • /sandbox/python 统一使用 PythonIsolatedRunner
  • Python 进程最多执行一次用户代码,执行后销毁;
  • Linux/Docker 环境固定启用 chrootno_new_privsseccompsetgid/setuid
  • 网络请求统一走父进程 HTTP 代理,不允许 Python 子进程直接网络 syscall。

目标

  • 保持 /sandbox/python API 兼容:成功返回 { success, data: { codeReturn, log } },失败返回 { success:false, message }
  • 兼容历史 Python Code 写法:main()main(variables)main(a,b)、全局变量注入、print 收集、内置 helper。
  • 多租户安全边界不依赖用户代码所在 Python 进程内的软限制。
  • 保留已有安全能力:模块白名单、文件限制、SSRF 防护、请求次数/大小限制、超时、输出限制、RSS 监控。
  • 降低冷启动延迟,但不复用执行过用户代码的 Python 解释器。

非目标

  • 不移除 JS ProcessPool
  • 不提供 Python pool 回滚模式。
  • 不把 AST 检查作为主要安全边界;它只作为纵深防御和兼容性辅助。
  • 不声称完整容器逃逸防护;OS 级隔离只约束当前 Python 执行进程的 syscall、根目录和权限。

总体架构

text
POST /sandbox/python
  -> queueIdLimiter.run(queueId, ...)
  -> PythonIsolatedRunner.execute({ code, variables })
  -> 获取干净的 one-shot 预热 Python 进程
     -> 无空闲进程时按需 spawn
  -> 预热进程已完成 chroot + no_new_privs + seccomp + setgid/setuid
  -> bootstrap 注入 helper、variables、受限 builtins
  -> exec 用户代码并调用 main
  -> stdout 输出 JSON line result/http_request
  -> 父进程解析结果、处理 HTTP 代理、监控超时/RSS/输出大小
  -> 该 Python 进程销毁,不归还池中
  -> 异步补充新的干净预热进程

/sandbox/modules 仍返回 env.SANDBOX_PYTHON_ALLOWED_MODULES,表示 Python 用户可直接 import 的白名单模块,不暴露底层 runner 类型。

核心模块

src/isolated/python-isolated-runner.ts

PythonIsolatedRunner 负责父进程侧调度和资源控制:

  • 维护 one-shot warm pool,默认预热 SANDBOX_POOL_SIZE 个空闲 Python 进程;
  • 预热进程只执行 init 协议,不执行用户代码;
  • execute() 优先使用空闲预热进程,没有空闲进程时按需创建;
  • 每个 Python 进程最多接受一条用户任务,任务结束后销毁;
  • 执行完成后异步补充新的干净预热进程;
  • 使用 Semaphore 控制 Python 任务最大并发,复用 SANDBOX_POOL_SIZE
  • 监控超时、输出大小和进程树 RSS;
  • 清理整个进程树,避免子进程残留;
  • 处理 Python 发起的 http_request IPC,并由父进程统一执行网络请求。

src/isolated/python-bootstrap.py

python-bootstrap.py 是 Python 子进程入口:

  • 支持两种协议:
    • 兼容模式:直接读取一条 task JSON 并执行;
    • 预热模式:先读取 type:"init",完成 native 隔离后输出 type:"ready",再等待一条 task JSON;
  • 初始化 Python helper:SystemHelpersystem_helperhttp_requestcount_tokenstr_to_base64create_hmacdelay
  • 注入 variables 和历史全局变量写法;
  • 安装受限 builtins、import 白名单、受限 open、危险属性拦截、audit hook;
  • 捕获 print 到内存 log,避免污染 stdout JSON line 协议;
  • 执行 main() / main(variables) / main(a,b)
  • 通过 stdout 输出 resulthttp_request JSON line。

native/python-sandbox

Go shared library fastgpt_python_sandbox.so 负责 Linux native 隔离:

  • chroot 到固定 Python sandbox root;
  • chdir("/")
  • setgroups([])
  • setgid(65537) / setuid(65537)
  • PR_SET_NO_NEW_PRIVS
  • 安装 seccomp filter;
  • 危险 syscall 默认拒绝,execve/execveatptracemount 等不能落地。

配置

Python 隔离相关配置已经收敛为内部安全默认,不提供运行时环境变量关闭或改弱:

配置当前值说明
Python 最大并发SANDBOX_POOL_SIZE复用现有进程池大小配置
预热空闲进程数SANDBOX_POOL_SIZE与 Python 最大并发保持一致
直接网络 syscallfalse统一走父进程 http_request 代理
chroot 根目录/tmp/fastgpt-python-sandboxDocker 构建阶段准备
用户代码 uid/gid65537:65537native 初始化后降权
native seccomp/chroot/setuidLinux 固定开启缺少 native 库或 chroot root 时 fail-closed

保留的 Python 业务配置:

变量说明
SANDBOX_PYTHON_ALLOWED_MODULES用户代码可直接 import 的 Python 模块白名单
SANDBOX_MAX_TIMEOUT单次执行最大超时
SANDBOX_MAX_MEMORY_MB子进程树 RSS 软限制
SANDBOX_MAX_OUTPUT_MBstdout/log 输出上限
SANDBOX_REQUEST_*父进程 HTTP 代理的次数、超时、请求体和响应体限制

网络代理

Python 子进程不允许直接使用网络 syscall。用户代码如需请求外部网络,必须调用:

python
http_request(url, method='GET', headers=None, body=None, timeout=None)
# 或
system_helper.http_request(...)
SystemHelper.httpRequest(...)

Python bootstrap 向父进程写出:

json
{
  "type": "http_request",
  "id": "http-1",
  "payload": {
    "url": "https://example.com",
    "method": "GET",
    "headers": {},
    "body": null,
    "timeout": null
  }
}

父进程执行:

  • URL 协议限制;
  • SSRF / 内网地址检查;
  • DNS pinning;
  • 请求次数限制;
  • 请求体大小限制;
  • 响应体大小限制;
  • 超时控制。

父进程再通过 stdin 返回:

json
{
  "type": "http_response",
  "id": "http-1",
  "success": true,
  "payload": {}
}

每个 Python 进程只执行一次任务,因此请求次数计数天然按单次执行归零。

安全边界

语言层边界

语言层限制用于减少误用和拦截已知危险能力,但不是主安全边界:

  • __import__ 白名单;
  • open 受限;
  • eval / exec / compile / globals / locals / vars / dir 等禁用;
  • __class____base____subclasses____globals__ 等危险属性拦截;
  • object builtin 替换;
  • audit hook 拦截 os.systemsubprocesssocketctypes 等事件;
  • AST 检查作为纵深防御。

OS 层边界

OS 层是多租户核心安全边界:

  • 每个 Python 子进程最多执行一次用户代码;
  • 执行用户代码前已经 chroot、降权、安装 seccomp;
  • 执行完成后整个进程树销毁;
  • 不复用执行过用户代码的解释器;
  • seccomp 不允许命令执行和高危系统调用;
  • chroot 只包含 Python 运行所需 stdlib、site-packages、动态库、证书、DNS 配置和 sandbox runtime 文件。

资源边界

  • 父进程定时采样子进程树 RSS,超过 SANDBOX_MAX_MEMORY_MB + RUNTIME_MEMORY_OVERHEAD_MB 后杀进程树;
  • 父进程控制总超时;
  • stdout JSON line 和收集到的 log 受 SANDBOX_MAX_OUTPUT_MB 限制;
  • 进程树清理由 killProcessTree() 处理,优先杀 descendant 和 process group。

Docker 和构建

Docker 镜像使用 Debian bookworm/glibc。Alpine/musl 下 Go c-shared .so 存在兼容风险,不能作为当前 Python native isolation 运行基线。

构建流程:

  • SANDBOX_BUILD_NATIVE_PYTHON=true pnpm build 构建 Go shared library;
  • build.sh 复制 python-bootstrap.pyfastgpt_python_sandbox.sodist
  • Docker runner 阶段安装 Python、numpy、pandas、matplotlib 和 native 依赖;
  • Docker 构建阶段准备 /tmp/fastgpt-python-sandbox chroot root;
  • code-sandbox 主进程保留 root,以便 Python 子进程在 native 初始化阶段执行 chroot/setuid;用户代码进程会降权到 sandbox 用户。

测试覆盖

兼容性

  • main()
  • main(variables)
  • main(a,b)
  • 全局变量注入;
  • print log;
  • 历史 helper;
  • 旧 Python Code 节点写法。

安全

  • GHSA-5jmh-5f2m-89jg 相关 __base__ / __subclasses__ 逃逸;
  • 动态 getattr
  • import os / sys / subprocess
  • 文件系统访问;
  • os.system / subprocess / socket / ctypes
  • 直接网络能力;
  • 父进程 HTTP 代理 SSRF 防护;
  • 请求大小、响应大小、请求次数、超时。

生命周期和资源

  • init 后存在干净预热进程;
  • 预热进程执行一次后销毁,不归还池中;
  • 执行后自动补充新的干净预热进程;
  • 并发超过上限时排队;
  • 超时后可恢复;
  • 内存超限后可恢复;
  • shutdown 清理 running / idle / warming 子进程。

Docker/Linux

  • native .so 加载;
  • setuid/setgid 降权;
  • chroot 生效;
  • seccomp 阻断命令执行;
  • numpy/pandas/matplotlib 在 chroot/seccomp 下可用;
  • Docker 包可用性测试覆盖 Python 和 JS 白名单包。

性能和资源

one-shot warm pool 的收益主要在低并发和空闲命中场景:

  • 空闲预热进程命中时,省去 Python spawn/bootstrap/native init 的一部分延迟;
  • 高并发短任务下,预热池可以覆盖 SANDBOX_POOL_SIZE 以内的首批请求,但每个进程执行一次后仍需销毁并补充,因此稳态吞吐仍不会接近旧长驻 pool;
  • 预热进程不提前 import pandas/numpy,避免 idle 内存过高;
  • 当前 Docker/seccomp 下,单个 idle Python bootstrap 进程 RSS 约 18.7MB;
  • SANDBOX_POOL_SIZE=20 时,Python idle 进程 RSS 粗略约 374MB;实际容器 RSS 还包含 JS worker、Node 主进程、共享页和系统库统计口径。

安全优先级高于短任务吞吐。如果未来需要继续优化,应优先评估:

  • 是否为重包场景做专门的 package page-cache 预热,而不是让 idle 进程提前 import;
  • 是否引入 clean forkserver。forkserver 父进程必须永不执行用户代码,且子进程在执行前完成 fd 清理、chroot、setuid 和 seccomp。

验收标准

  • 旧 Python pool/worker 代码删除;
  • /sandbox/python 只使用 PythonIsolatedRunner
  • Linux 缺少 native .so 或 chroot root 时 fail-closed;
  • Python 子进程直接网络 syscall 固定关闭;
  • 执行过用户代码的 Python 进程不会复用;
  • 原有 Python 兼容用例通过;
  • 原有安全边界测试迁移并通过;
  • Docker/seccomp 下 Python 包可用性和 OS 隔离测试通过。

当前验证命令

bash
pnpm --filter @fastgpt/code-sandbox exec tsc --noEmit
SANDBOX_MAX_MEMORY_MB=256 pnpm --filter @fastgpt/code-sandbox exec vitest run --coverage.enabled=false
pnpm --filter @fastgpt/code-sandbox build
docker build --build-arg proxy=1 -f projects/code-sandbox/Dockerfile -t fastgpt-code-sandbox-warm-pool .