学习 Dify 的代码沙箱

在上一篇文章中,我们讲到了 Dify 的工具系统,其中有一个代码执行的内置工具非常重要,无论是在工作流的代码节点中执行用户代码,还是在智能体中作为 Code Interpreter 调用,都离不开这个工具。为了执行用户代码,Dify 需要一个安全的、隔离的、可控的代码执行环境,这也就是本文的主角 ------ Dify 代码沙箱

如果没有完善的沙箱隔离机制,恶意用户可以利用代码执行漏洞访问系统文件、盗取数据、甚至获得整个服务器的控制权。因此,Dify 在代码沙箱的设计和实现上下了不少功夫,其中有不少值得学习的点。本文将详细介绍 Dify 代码沙箱的工作原理,以及它所采用的各种安全隔离技术。

代码沙箱概述

代码沙箱(Code Sandbox) 是一个隔离的代码执行环境,允许在受控的环境中安全地运行不信任的代码。它的核心目标是在提供代码执行功能的同时,防止恶意或有缺陷的代码对系统造成危害。

在 Dify 中,代码沙箱主要用于以下场景:

  1. 工作流代码节点:用户可以在工作流中添加代码节点,用 Python 或 JavaScript 处理工作流中的数据
  2. 代码执行工具:作为智能体的工具,大模型可以自主调用代码执行器完成计算任务
  3. 模板转换:在数据处理过程中,使用代码对数据进行转换和清洗

这些场景的共同点是,代码来自于用户或 AI 生成,具有不可预测性,因此必须在沙箱中执行。

沙箱的安全需求

设计一个安全的代码沙箱需要满足以下需求:

需求 说明
进程隔离 用户代码运行在独立的进程中,不影响主程序
文件系统隔离 代码无法访问主机系统的文件和目录
网络隔离 代码可以根据配置选择性地访问网络
系统调用限制 代码不能调用危险的系统调用(如 fork、exec 等)
权限限制 代码以低权限用户身份运行
资源限制 限制代码的 CPU、内存和执行时间
代码隐私 用户的代码在传输和执行过程中受到保护

Dify 通过一个独立的沙箱服务 dify-sandbox 来实现这些需求,它采用了 Linux 提供的多种安全机制,构建了一套分层的防御体系。

代码执行的实现

在深入 Dify 的代码沙箱之前,让我们来看下代码执行相关的逻辑,其实现位于 CodeExecutor 类:

python 复制代码
class CodeExecutor:

  @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,
    }

    # 发送请求
    response = post(
      str(url),
      json=data,
      headers=headers,
      timeout=Timeout(
        connect=dify_config.CODE_EXECUTION_CONNECT_TIMEOUT,
        read=dify_config.CODE_EXECUTION_READ_TIMEOUT,
        write=dify_config.CODE_EXECUTION_WRITE_TIMEOUT,
        pool=None,
      ),
    )

    # 获取 stdout 输出
    response_data = response.json()
    response_code = CodeExecutionResponse(**response_data)
    return response_code.data.stdout or ""

  @classmethod
  def execute_workflow_code_template(cls, language: CodeLanguage, code: str, inputs: Mapping[str, Any]):
    """
    执行工作流代码节点
    """

    # 将用户代码和输入参数嵌入预置的代码模板
    template_transformer = cls.code_template_transformers.get(language)
    runner, preload = template_transformer.transform_caller(code, inputs)

    try:
      # 调用代码执行
      response = cls.execute_code(language, preload, runner)
    except CodeExecutionError as e:
      raise e

    # 将执行结果转换为工作流的节点出参
    return template_transformer.transform_response(response)

其中 execute_workflow_code_template() 函数负责工作流中的代码节点的执行,注意它并没有直接执行用户的代码,而是做了一层模板转换。因为代码执行服务是通过标准输出获取执行结果的 ,而工作流的代码节点中用户输入的代码必须包含一个 main() 入口:

python 复制代码
def main(x: int, y: int) -> dict:
  return {
    'sum' : x + y
  }

如果直接执行不会有任何输出,所以 Dify 在调用代码执行服务之前,先使用一段预置的代码模板将用户代码和输入参数包起来。模板如下:

python 复制代码
# 用户代码,申明 main 函数
{cls._code_placeholder}

import json
from base64 import b64decode

# 输入参数
inputs_obj = json.loads(b64decode('{cls._inputs_placeholder}').decode('utf-8'))

# 执行 main 函数
output_obj = main(**inputs_obj)

# 将输出转换为 JSON 格式并打印输出
output_json = json.dumps(output_obj, indent=4)
result = f'''<<RESULT>>{{output_json}}<<RESULT>>'''
print(result)

最终的输出结果会被转换为 JSON 格式并通过 print 打印出来,打印的时候加上 <<RESULT>> 这个特殊标签,防止 main 函数里其他的 print 对结果造成干扰,方便解析。

另外,CodeExecutor 中还有一个 execute_code() 函数,这才是真正的代码执行入口,支持 Python 或 JavaScript 两种编程语言,它负责将代码发送到代码沙箱服务并处理返回结果:

rust 复制代码
$ curl -X POST http://127.0.0.1:8194/v1/sandbox/run \
  -H "X-Api-Key: dify-sandbox" \
  -H "Content-Type: application/json" \
  -d '{
    "language": "python3",
    "code": "print('"'"'hello'"'"')",
    "preload": "",
    "enable_network": true
  }'

该接口通过 X-Api-Key 头支持简单的鉴权,该值可以在 .env 文件中修改:

ini 复制代码
CODE_EXECUTION_ENDPOINT=http://127.0.0.1:8194
CODE_EXECUTION_API_KEY=dify-sandbox

Dify 代码沙箱架构

Dify 的代码沙箱服务以 Docker 容器运行,采用了 防御纵深(Defense in Depth) 的设计思想,实现了多层安全防御,即使某一层防御被突破,其他层仍然能够保护系统。其执行流程如下:

整个过程涉及多个安全层,下面我们逐一探讨。

第一层:代码加密与编码

Dify 的代码沙箱在执行用户代码时,首先会将用户代码写到一个临时文件中,然后再启动 Python 或 Node.js 去运行该脚本文件,通过捕获 Python 或 Node.js 进程的 stdoutstderr 获取代码执行结果。

为了防止用户的代码以明文形式出现在磁盘上,Dify 在写入临时文件时对代码做了一次简单的加密:

go 复制代码
// 生成一个 512 字节的随机密钥
key_len := 64
key := make([]byte, key_len)
_, err := rand.Read(key)

// 加密代码:采用简单的 XOR 加密,将代码与密钥进行 XOR 操作
encrypted_code := make([]byte, len(code))
for i := 0; i < len(code); i++ {
    encrypted_code[i] = code[i] ^ key[i%key_len]
}

// 对加密后的代码进行 Base64 编码
code = base64.StdEncoding.EncodeToString(encrypted_code)
// 对密钥进行 Base64 编码
encoded_key := base64.StdEncoding.EncodeToString(key)

用户的代码也不是直接执行的,而是通过另一个模板文件 prescript.py 动态生成的,可以在这个模板文件中看到用户代码的解密过程:

python 复制代码
from base64 import b64decode

# 解码密钥
key = b64decode(key)

# 解码用户代码
code = b64decode("{{code}}")

# 定义解密函数
def decrypt(code, key):
  key_len = len(key)
  code_len = len(code)
  code = bytearray(code)
  for i in range(code_len):
    code[i] = code[i] ^ key[i % key_len]  # XOR 操作
  return bytes(code)

# 解密用户代码
code = decrypt(code, key)

# 执行用户代码
exec(code)

虽然 XOR 加密的安全性相对较弱,但由于这个密钥是一次性的,考虑到所有操作都在容器中,以及配合其他安全机制,这种方案在实践中是足够的。

第二层:进程隔离

每次代码执行都会创建一个独立的子进程,用户代码在这个进程中运行,与沙箱服务的主进程完全隔离。这样,即使代码因某种原因崩溃或消耗过多资源,也不会影响沙箱服务本身。另外,参考 docker/volumes/sandbox/conf/config.yaml 文件,沙箱还支持配置以下资源限制:

配置项 说明 默认值
max_workers 最大并发执行进程数 4
max_requests 请求队列大小 50
worker_timeout 单个代码执行的超时时间 5 秒

超过超时时间的进程会被强制杀死,这是一个重要的资源保护机制。

第三层:文件系统隔离

chroot 是 Linux 提供的一个系统调用,可以改变进程的根目录。当一个进程执行 chroot("/some/path") 后,对于这个进程来说,/some/path 就变成了新的根目录,进程无法访问此目录之外的任何文件。

在 Dify 沙箱中,Python 代码执行时会被 chroot 到一个特殊的沙箱目录,如 /var/sandbox/sandbox-python 目录。这个目录包含了 Python 运行时所需的最小文件集合。这样,即使用户代码尝试执行 open("/etc/passwd"),它实际上会尝试打开 /var/sandbox/sandbox-python/etc/passwd,而这个文件并不存在,因此访问会被拒绝。

这个最小化的文件系统被称为 chroot 监狱,即使用户代码知道绝对路径,也无法访问这个监狱之外的文件。

Dify 沙箱通过 Go 语言的 syscall.Chroot() 系统调用实现该功能:

go 复制代码
import "syscall"

func InitSeccomp(uid int, gid int, enable_network bool) error {

  err := syscall.Chroot(".")

  // 其他安全措施...
}

需要注意的是,chroot 本身并不是一个强大的安全机制,如果进程有能力调用 chdir() 和其他系统调用,可能找到逃逸的方式,需要配合其他机制来形成完整的防护。因此,Dify 在应用 chroot 的同时,还使用了 seccomp 来限制进程可以调用的系统调用,后面我们会具体介绍。

第四层:用户权限隔离

沙箱中的代码不应该以 root 身份运行。Dify 在代码执行时,会使用 setuidsetgid 系统调用,将进程的用户身份和组身份切换到一个非特权用户:

go 复制代码
import "syscall"

func InitSeccomp(uid int, gid int, enable_network bool) error {

  // 其他安全措施...

  // setuid
  err = syscall.Setuid(uid)

  // setgid
  err = syscall.Setgid(gid)
}

这个非特权用户名为 sandbox,ID 为 65537,是由沙箱服务在启动时自动创建的。

这样做的好处是:

  1. 限制文件和目录的访问权限(基于文件的 Unix 权限位)
  2. 防止进程获得 root 权限进行的操作
  3. 减小代码逃逸后的影响范围

除了权限降级外,Dify 还使用了 prctl(PR_SET_NO_NEW_PRIVS) 系统调用来禁止进程及其所有子进程获得新的权限:

go 复制代码
import "github.com/langgenius/dify-sandbox/internal/core/lib"

func InitSeccomp(uid int, gid int, enable_network bool) error {

  // 其他安全措施...

  lib.SetNoNewPrivs()

  // 其他安全措施...
}

其中 SetNoNewPrivs() 函数通过调用 Go 语言的 syscall.Syscall6() 系统调用设置进程的 PR_SET_NO_NEW_PRIVS 标志:

go 复制代码
func SetNoNewPrivs() error {
  // syscall.SYS_PRCTL 表示 Linux 的 prctl 系统调用,用于操作进程的各种属性
  // 它的第一个参数 0x26 是 PR_SET_NO_NEW_PRIVS 标志常数的十六进制值
  // 它的第二个参数 1 表示启用该标志
  _, _, e := syscall.Syscall6(syscall.SYS_PRCTL, 0x26, 1, 0, 0, 0, 0)
}

这个标志的作用是,即使进程调用了一个设置了 setuid 位的二进制文件(如 sudo),也无法获得额外的权限。这是一个额外的保护层,防止通过特殊的二进制文件实现权限提升。

第五层:系统调用隔离

Seccomp(Secure Computing Mode) 是 Linux 内核提供的一个强大的安全机制,它允许进程通过 BPF(Berkeley Packet Filter) 程序来过滤系统调用。当进程执行被禁用的系统调用时,内核会立即终止该进程或返回错误。

Seccomp 使用 BPF 字节码来实现系统调用过滤,当进程执行系统调用时,内核会执行这个 BPF 程序来决定是否允许该调用。BPF 程序的结果有几种可能:

返回值 说明
SECCOMP_RET_ALLOW 允许系统调用执行
SECCOMP_RET_ERRNO 返回错误码,进程继续执行
SECCOMP_RET_KILL_PROCESS 杀死整个进程(推荐)
SECCOMP_RET_KILL_THREAD 杀死当前线程
SECCOMP_RET_TRAP 发送信号给进程

Dify 采用 白名单模式,即只有显式允许的系统调用才能执行,其他所有系统调用都会被拒绝。Dify 针对不同的编程语言(Python 和 Node.js)以及不同的系统架构(ARM64 和 AMD64)提供了不同的白名单:

  • internal/static/python_syscall/ - Python 允许的系统调用
  • internal/static/nodejs_syscall/ - Node.js 允许的系统调用

比如针对 ARM64 架构下的 Python 语言,白名单如下:

这个白名单分三个部分:

  • ALLOW_SYSCALLS 允许的系统调用
  • ALLOW_ERROR_SYSCALLS 允许的系统调用,但是返回报错
  • ALLOW_NETWORK_SYSCALLS 允许的网络系统调用

可以看到,危险的系统调用如 execveforkptrace 等都被禁止了,这确保了用户代码无法执行任意的系统命令或创建新进程。

系统调用隔离的实现同样位于 InitSeccomp 函数中:

go 复制代码
import "github.com/langgenius/dify-sandbox/internal/core/lib"

func InitSeccomp(uid int, gid int, enable_network bool) error {

  // 其他安全措施...

  allowed_syscalls = append(allowed_syscalls, python_syscall.ALLOW_SYSCALLS...)
  if enable_network {
    allowed_syscalls = append(allowed_syscalls, python_syscall.ALLOW_NETWORK_SYSCALLS...)
  }
  allowed_not_kill_syscalls = append(allowed_not_kill_syscalls, python_syscall.ALLOW_ERROR_SYSCALLS...)

  err = lib.Seccomp(allowed_syscalls, allowed_not_kill_syscalls)

  // 其他安全措施...
}

其中 lib.Seccomp() 函数的实现如下:

go 复制代码
import (
	"syscall"
	sg "github.com/seccomp/libseccomp-golang"
)

func Seccomp(allowed_syscalls []int, allowed_not_kill_syscalls []int) error {
    
  // 初始化 Seccomp 过滤器
  ctx, err := sg.NewFilter(sg.ActKillProcess)

  // 添加规则:允许的系统调用
  for _, syscall := range allowed_syscalls {
    ctx.AddRule(sg.ScmpSyscall(syscall), sg.ActAllow)
  }

  // 添加规则:报错的系统调用
  for _, syscall := range allowed_not_kill_syscalls {
    ctx.AddRule(sg.ScmpSyscall(syscall), sg.ActErrno)
  }

  // 将过滤器规则导出成字节码
  file := os.NewFile(uintptr(writer.Fd()), "pipe")
  ctx.ExportBPF(file)

  // 应用 Seccomp 规则
  _, _, err2 := syscall.Syscall(
    SYS_SECCOMP,
    uintptr(SeccompSetModeFilter),
    uintptr(SeccompFilterFlagTSYNC),
    uintptr(unsafe.Pointer(&bpf)),
  )

它通过 libseccomp-golang 库,创建 Seccomp 过滤器并添加对应的白名单规则。

第六层:网络隔离

除了上述安全机制外,Dify 还允许根据配置选择性地启用或禁用网络访问:

yaml 复制代码
enable_network: True

这是通过上面的 Seccomp 过滤器来实现的,当禁用网络后,所有和网络相关的系统调用(比如 socketconnect 等)都将被拒绝。

另外,细心的朋友可能会注意到,在 Docker Compose 部署中,沙箱服务是运行在一个隔离的网络中,使用 SSRF 代理容器来控制出站连接:

yaml 复制代码
sandbox:
  image: langgenius/dify-sandbox:0.2.12
  environment:
    ENABLE_NETWORK: ${SANDBOX_ENABLE_NETWORK:-true}
    HTTP_PROXY: ${SANDBOX_HTTP_PROXY:-http://ssrf_proxy:3128}
    HTTPS_PROXY: ${SANDBOX_HTTPS_PROXY:-http://ssrf_proxy:3128}
  networks:
    - ssrf_proxy_network

因此即使代码绕过了 Seccomp 限制(理论上不可能),Docker 网络层也会提供额外的防护。

共享库的奥秘

看到这里,大家可能会疑惑,上述大多数的安全措施都位于 InitSeccomp() 函数中,这是一个 Go 函数,它是如何作用在用户编写的 Python 或 Node.js 脚本上的呢?

答案在于 python.so 这个 C 共享库。

Dify 通过 CGO(Go 的 C 互操作机制)将 InitSeccomp() 中的防护逻辑编译成一个 so 库文件,编译命令如下:

bash 复制代码
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build \
  -o internal/core/runner/python/python.so \
  -buildmode=c-shared \
  -ldflags="-s -w" \
  cmd/lib/python/main.go

其中 CGO_ENABLED=1 表示启用 CGO,允许 Go 调用 C 代码;GOOS=linux 表示目标操作系统为 Linux,Dify 的沙箱服务只能跑在 Linux 环境下;-buildmode=c-shared 表示编译为 C 共享库格式;最后的 -ldflags="-s -w" 用于移除符号表和调试信息,减小文件大小。

编译后的 python.so 文件主要导出函数是 DifySeccomp,它在 cmd/lib/python/main.go 中定义:

go 复制代码
package main

import (
  "github.com/langgenius/dify-sandbox/internal/core/lib/python"
)
import "C"

// 导出的函数
func DifySeccomp(uid int, gid int, enable_network bool) {
  python.InitSeccomp(uid, gid, enable_network)
}

func main() {}

这个函数被 Python 脚本通过 ctypes 调用,在用户代码执行前完成所有的安全隔离设置:

python 复制代码
import ctypes

# 动态加载 python.so,这是 Dify 沙箱的 C 共享库
lib = ctypes.CDLL("./python.so")
lib.DifySeccomp.argtypes = [ctypes.c_uint32, ctypes.c_uint32, ctypes.c_bool]
lib.DifySeccomp.restype = None

# 应用安全隔离:chroot + seccomp + setuid + SetNoNewPrivs
lib.DifySeccomp({{uid}}, {{gid}}, {{enable_network}})

# 执行用户代码 ...
code = decrypt(code, key)
exec(code)

实际应用示例

让我们通过一个例子来理解 Seccomp 的实际效果,假设用户在工作流的代码节点中尝试执行以下恶意操作:

python 复制代码
def main() -> dict:    
  return {
    "result": "hello",
  }

# 尝试删除整个系统
import os
os.system("rm -rf /")

当 Python 执行 os.system() 时,实际上会调用 execve 系统调用来启动新的进程。但由于 execve 被 Seccomp 过滤器拒绝了,内核会立即终止这个进程并返回错误:

这种保护是在内核级别进行的,任何绕过尝试都会失败。

小结

代码沙箱作为 AI 应用安全的基石,其重要性不言而喻。我们今天详细学习了 Dify 代码沙箱的工作原理,它巧妙地结合了 Linux 提供的多种安全机制:

  • Chroot 提供文件系统隔离
  • Setuid/Setgid 实现权限降级
  • SetNoNewPrivs 防止权限提升
  • Seccomp 在系统调用级别进行细粒度的控制

通过这些机制的组合,Dify 实现了进程隔离、文件系统隔离、用户权限隔离、系统调用隔离及网络隔离等多层防御,打造了一个既安全又实用的代码执行环境,允许 AI 应用能够动态执行代码,同时避免恶意代码对系统造成伤害。

欢迎关注

如果这篇文章对您有所帮助,欢迎关注我的同名公众号:日习一技,每天学一点新技术

我会每天花一个小时,记录下我学习的点点滴滴。内容包括但不限于:

  • 某个产品的使用小窍门
  • 开源项目的实践和心得
  • 技术点的简单解读

目标是让大家用5分钟读完就能有所收获,不需要太费劲,但却可以轻松获取一些干货。不管你是技术新手还是老鸟,欢迎给我提建议,如果有想学习的技术,也欢迎交流!

相关推荐
NAGNIP8 小时前
一文搞懂深度学习中的通用逼近定理!
人工智能·算法·面试
冬奇Lab9 小时前
一天一个开源项目(第36篇):EverMemOS - 跨 LLM 与平台的长时记忆 OS,让 Agent 会记忆更会推理
人工智能·开源·资讯
冬奇Lab9 小时前
OpenClaw 源码深度解析(一):Gateway——为什么需要一个"中枢"
人工智能·开源·源码阅读
AngelPP13 小时前
OpenClaw 架构深度解析:如何把 AI 助手搬到你的个人设备上
人工智能
宅小年13 小时前
Claude Code 换成了Kimi K2.5后,我再也回不去了
人工智能·ai编程·claude
九狼13 小时前
Flutter URL Scheme 跨平台跳转
人工智能·flutter·github
ZFSS14 小时前
Kimi Chat Completion API 申请及使用
前端·人工智能
天翼云开发者社区15 小时前
春节复工福利就位!天翼云息壤2500万Tokens免费送,全品类大模型一键畅玩!
人工智能·算力服务·息壤
知识浅谈15 小时前
教你如何用 Gemini 将课本图片一键转为精美 PPT
人工智能
Ray Liang15 小时前
被低估的量化版模型,小身材也能干大事
人工智能·ai·ai助手·mindx