如何在 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后端,并明确限定为可信输入。 - 公开部署连接已配置权限和资源限制的执行后端。
- 部署环境无法提供隔离时,只开放经过设计的受限语法,或关闭服务端自由代码执行。
应用可以保留跨平台接口,同时让部署者清楚哪些能力依赖执行环境。工作目录创建、文件权限、超时终止和任务清理,都应由执行后端统一负责。
备注:内容仅供参考。
