PAYATHON 2026

如何在 shiny 应用中实现更安全的 R console?

技术老齐

公开运行任意 R 代码,需要独立的执行边界

不要在 Shiny 主进程中直接对学生提交的代码执行 eval(parse(...))。面向不可信用户时,应在受操作系统限制的独立环境中执行代码,并限制文件访问、网络、运行时间、内存和输出量。

eval() 的 envir 参数可以隔离变量,setwd() 可以设置相对路径的起点,但两者都不能限制代码的系统权限。即使单独启动 R 子进程,也需要配置权限和资源限制。

支持任意 R 代码、可靠隔离不可信用户,以及完全跨平台且不增加部署复杂度,这些要求很难同时满足。开源应用可以统一执行接口,实际的安全边界仍需由部署环境提供。

为什么换一个 environment 还不够

下面的写法可以减少学生变量直接混入应用环境的情况:

env <- new.env(parent = baseenv())
eval(parse(text = input$console_in), envir = env)

代码仍在当前 R 进程中运行,拥有该进程的操作系统权限。它可能读取或删除文件、访问网络、启动外部命令,也可能通过无限循环或大量分配内存拖垮服务。Shiny 的多个会话还可能共享同一个 R 进程。

常见措施各有用途,也各有限制:

措施可以解决什么不能保证什么
独立的 environment减少变量名冲突文件、网络或进程隔离
tryCatch()处理普通 R 错误阻止副作用、内存耗尽或进程退出
函数名黑名单拦截部分直接调用安全执行任意 R 代码
setTimeLimit()限制部分计算的运行时间在所有原生代码和阻塞调用中及时中断
独立 R 子进程分离进程状态,便于终止任务自动降低文件和网络权限

例如,仅禁止字符串 system 无法提供可靠防护。R 支持动态查找函数、命名空间访问和间接调用,按文本过滤很容易遗漏执行路径。

如果课程只需要四则运算或少量固定操作,可以定义受限语法,检查解析后的表达式,再用自己实现的解释器执行。这样更容易控制代码能做什么,但学生能使用的也只是 R 语法的一个子集。

如何安排代码执行

需要完整 R 功能的课程,可以采用以下执行流程:

Shiny 接收代码
    ↓
执行服务创建独立任务
    ↓
受限环境中的 R 进程运行代码
    ↓
返回受大小限制的文本、图像或评分结果
    ↓
销毁执行环境

部署时至少需要做到:

  • 使用专门的低权限身份执行代码,禁止其读取 Shiny 的配置、凭据、应用源码或其他学生的文件。
  • 每次提交使用独立工作目录。教学数据以只读方式提供,写入范围限定在任务目录内。
  • 默认关闭不需要的网络访问,避免代码访问内部服务或向外发送数据。
  • 从执行环境外部限制时间、内存、进程数量、磁盘和输出量。超时后终止整个任务,包括它启动的后代进程。
  • 限制每个用户的并发数和提交频率,避免大量任务耗尽服务资源。

容器、虚拟机或操作系统沙箱都可以用来构建执行边界,前提是配置正确。仅把代码放进容器并不能保证安全。例如,挂载宿主机敏感目录或容器管理套接字,就可能破坏隔离。

默认在每次提交结束后销毁执行环境,通常更容易清理状态。如果教学需要保留学生的变量,可以为每个会话分配执行环境,同时设置闲置回收和资源配额。

工作目录应在执行进程中指定

R 的当前工作目录是进程状态,同一进程中的 environment 和 Shiny 会话不会各自拥有独立的工作目录。因此,不要在共享的 Shiny 进程里根据学生请求调用 setwd(),否则可能影响同一进程中的其他会话,以及应用自身的文件操作。

任务目录应由服务器创建,并在启动执行进程时指定。不要直接接受用户提交的任意路径。

设置工作目录与限制文件访问是两回事:

setwd("/some/task-directory")

这只会改变相对路径的解析起点。只要操作系统权限允许,代码仍然可以访问绝对路径、../ 指向的文件,或者再次调用 setwd()。限制可访问的文件范围,需要依靠文件权限、受限挂载或其他操作系统机制。

如果需要在专用 worker 内部临时切换目录,可以用以下方式在结束时恢复:

with_workdir <- function(path, code) {
  old <- setwd(path)
  on.exit(setwd(old), add = TRUE)
  force(code)
}

这个函数只管理目录状态,不要用它包装 Shiny 主进程中的不可信代码执行。

使用 callr 分离进程的示例

callr 可以在独立 R 进程中求值,并通过 wd 设置该进程的初始工作目录。下面的示例实现了进程分离、目录清理和普通文本输出捕获,适合本地原型或可信教学环境。它没有建立操作系统沙箱,不能直接用作公开服务的安全方案。

run_local_code <- function(code, timeout = 5) {
  stopifnot(
    is.character(code),
    length(code) == 1L,
    !is.na(code)
  )

  workdir <- tempfile("r-exercise-")
  if (!dir.create(workdir)) {
    stop("Cannot create working directory")
  }
  on.exit(unlink(workdir, recursive = TRUE), add = TRUE)

  callr::r(
    func = function(code) {
      env <- new.env(parent = baseenv())

      tryCatch({
        expressions <- parse(text = code)

        output <- capture.output({
          for (expr in expressions) {
            result <- withVisible(eval(expr, envir = env))
            if (result$visible) {
              print(result$value)
            }
          }
        })

        list(ok = TRUE, output = output)
      }, error = function(e) {
        list(ok = FALSE, output = conditionMessage(e))
      })
    },
    args = list(code = code),
    wd = workdir,
    timeout = timeout
  )
}

例如:

run_local_code("x <- 1:5; mean(x)")

使用时需要注意:

  • 子进程通常仍以启动它的用户身份运行,能够访问该身份有权限访问的资源。
  • timeout 有助于控制等待时间,内存、磁盘和后代进程仍需额外限制。
  • capture.output() 会在内存中累积输出。生产环境应在接收输出时限制大小,等运行结束后再截断已经太晚。
  • 示例只捕获普通文本输出,没有完整处理警告、消息、绘图和交互式输入。
  • 同步调用 callr::r() 会阻塞当前 Shiny 进程,使其无法处理其他工作。正式应用应通过后台任务或执行服务提交代码,再异步接收结果。
  • 超时或启动失败也可能在父进程中抛出错误,需要由调用方处理。

不可信执行服务返回的数据也应视为不可信。优先使用有大小限制、结构可校验的文本或 JSON,不要把任意 R 对象带回 Shiny 主进程继续求值。展示输出时,应使用 renderText() 等会转义文本的方式,避免将学生输出直接作为 HTML 插入页面。

如何兼顾开源发布与部署简便

可以将代码执行封装成独立接口,让 Shiny 负责提交代码、查询任务状态和展示结果:

  • 本地开发使用 callr 后端,并明确限定为可信输入。
  • 公开部署连接已配置权限和资源限制的执行后端。
  • 部署环境无法提供隔离时,只开放经过设计的受限语法,或关闭服务端自由代码执行。

应用可以保留跨平台接口,同时让部署者清楚哪些能力依赖执行环境。工作目录创建、文件权限、超时终止和任务清理,都应由执行后端统一负责。

备注:内容仅供参考。