系列导航

导读 · 上一篇:37:Ruby 惯用法与必要优化 · 下一篇:39:综合交付与可复现使用 · 完整源码包

外部输入只能选择已授权操作

Ruby 能根据字符串调用方法、执行代码、加载对象和创建进程。这些能力用于内部扩展很方便,但若外部输入可以直接决定目标,就可能把一个“导入任务”接口扩展成任意文件访问或任意代码执行。安全设计需要先列出允许的操作,再把输入约束到这组操作。

Taskbook 的正常输入是任务数据,包含标题、优先级、标签和状态。它不需要用户提交 Ruby 类名、任意方法名或 shell 片段。拒绝这些额外解释能力,可以缩小整个输入面的复杂度,同时让错误处理保持可预测。

本章实验只使用临时目录与合成数据,分别验证文件名越界、命令参数解释、动态调用白名单和 YAML 类标签拒绝。测试证明这些具体边界生效,没有声称代码构成可执行任意敌对程序的沙箱。

文件路径先定义可接受名称

从仓库根目录运行:

1
ruby examples/ruby/labs/38/run.rb

实验只接受由小写字母、数字、下划线和连字符组成、以 json 结尾的单层文件名:

1
2
raise ArgumentError, 'invalid document' unless
name.match?(/\A[a-z0-9_-]+\.json\z/)

使用字符串绝对起止锚点,避免只匹配一部分输入。目录由程序创建并控制,用户只选择其中一个文档名称。相对父目录、绝对路径和嵌套路径都不属于接口所需能力,因此直接拒绝,比先拼接任意路径再猜测是否合法更易检查。

简单的字符串前缀校验不够。例如允许根目录为某个 tasks 路径时,旁边一个同名前缀目录也可能通过 starts_with;路径规范化与真实文件解析又会受到符号链接影响。目录边界判断必须按路径组成与文件系统语义实现,不能用文本相似性替代。

本实验额外拒绝符号链接,但明确依赖私有临时目录。检查之后到打开之前仍存在时间差,如果攻击者能并发替换目录项,检查与使用之间就可能发生竞争。面对敌对文件系统,需要平台支持的安全打开方式和受控目录描述符,不能把一次 symlink? 检查宣传为完整沙箱。

错误不能把内部细节返回给输入方

合法文档必须能读取并解析,非法文件名、缺失文件和错误语法则返回相同的简短错误。实验检查错误消息精确等于 invalid document,避免把内部绝对路径、调用栈或原始系统异常直接放入用户响应。

对外收敛错误不等于内部完全没有诊断。服务端可以记录受控错误类别和请求标识,但不应自动记录完整输入内容、凭据或文件正文。日志本身也有读取权限与保留期限,不能把敏感输入从 HTTP 响应移动到公开日志便当成解决。

测试中使用缺失文件很有价值:正常路径只会证明文件可读,无法暴露错误信息泄漏。还应分别覆盖解析失败与访问拒绝,因为它们经过不同异常路径。捕获范围应具体,避免所有 StandardError 都被包装成输入问题,使程序 bug 长期隐藏。

路径安全也不能替代输入大小限制。一个合法名称对应的巨大文件仍可消耗内存和 CPU。Taskbook 主工程在读取时限制字节数,再检查编码和字段;本章小型路径实验只解释名称边界,实际接口需要组合这些已有约束。

命令参数保持数据身份

实验把包含分号和 touch 文本的字符串作为参数传给受控 Ruby 子进程:

1
2
3
4
5
Open3.capture3(
RbConfig.ruby, '-e', 'print ARGV.fetch(0)',
'; touch NEVER_CREATED',
chdir: root
)

验收要求子进程原样打印这个字符串,并且临时目录中没有出现 NEVER_CREATED 文件。数组参数形式让这段内容作为单个参数进入目标进程,而不交给 shell 解释其中分号。测试同时检查返回内容与缺失副作用,避免只看退出码。

如果把用户输入插入一个整体命令字符串,shell 元字符可能获得特殊含义。对确实需要 shell 的内部脚本,应把固定脚本与数据通道分开;不要靠删掉几个字符维护不完整黑名单。命令行解析器的规则复杂,黑名单很容易遗漏另一种表达。

参数数组也不是对任意程序都安全。目标程序仍可能把以短横线开头的值当作选项,或把特定参数解释成脚本。需要按目标工具契约使用选项终止符、固定可执行文件、限制参数值,并检查返回状态。这里目标是受控 Ruby 程序,参数只被打印,不存在额外解释。

子进程的工作目录和环境同样影响行为。相对可执行文件、搜索路径和继承凭据会扩大能力范围。实验使用当前解释器的绝对路径和私有临时工作目录;真实集成应逐项决定哪些环境变量需要继承,而不是把整个运行环境无条件传给外部工具。

动态方法名用映射收敛

public_send 只限制可见性,不代表被调用的所有公开方法都适合暴露给外部输入。任意方法名即使避开私有方法,也可能到达具有副作用的 API。因此实验使用固定映射:

1
2
commands = {'count' => ->(items) { items.length }}.freeze
commands.fetch(external_name).call(items)

允许 count,拒绝 instance_eval。映射同时定义外部名称和内部操作,便于代码审查及生成帮助信息。外部名称不需要与内部方法名一一相同,也不需要转换成任意常量。

这类映射是能力白名单。新增操作要经过显式代码变更,才能拥有新的权限;接口不会因为依赖升级多出一个公开方法,就自动扩大用户可调用范围。白名单中的操作仍需验证参数,不能把“方法名合法”当成“所有输入合法”。

别名和默认操作也应测试。未知名称若自动退回某个有副作用的默认方法,拒绝逻辑就失去意义。实验使用 fetch,使未授权名称明确失败;上层再把 KeyError 转成稳定的协议错误,避免把内部映射内容暴露给客户端。

反序列化只恢复数据形状

Marshal 和能够恢复任意对象的反序列化模式,会把外部字节解释成 Ruby 对象结构。对于任务导入,通常只需要数组、字典、字符串和数字,没有恢复任意类实例的业务需求。限制数据格式可以减少加载类与特殊反序列化钩子的风险。

实验使用 Psych.safe_load,显式空类列表、空符号列表并禁用 aliases,要求 Ruby 对象标签触发 DisallowedClass。检查预期异常类别,比“读取失败了”更具体;如果失败只是语法错误,就没有真正覆盖对象恢复权限。

safe_load 名称也不意味着输入没有资源成本。极大文本、深层嵌套、重复结构仍需按使用库设置限制,并在解析后验证字段与类型。JSON 作为任务格式也要禁用不需要的对象扩展能力,检查顶层数组、已知字段、重复标识和状态范围。

不能把不可信 Marshal 数据先 load 再检查对象类型。危险行为可能发生在加载过程中,事后校验已经太晚。边界应在解释之前收敛:拒绝这种格式,或只接受明确安全的数据表示,并由程序主动构造业务对象。

本次拒绝案例与目录威胁模型

本次实验拒绝父目录路径、绝对路径、缺失文件和符号链接;命令参数原样返回,未创建注入字符串中的目标文件;动态操作拒绝 instance_eval,Psych 5.2.2 拒绝任意对象标签。日志保存了每个通过的反例名称,不能由一个总的 pass 推断其他未测输入也安全。

目录由本进程创建,其他不可信用户无法写入,是文件实验的重要前提。若部署改成多人共享上传目录,原来的符号链接检查就不再提供相同保证。安全规则需要跟部署权限一起审查;代码完全不变,威胁模型改变也会使结论失效。

允许输入与拒绝输入应成对维护。只写攻击字符串测试,可能得到一个拒绝所有请求的“安全”实现;只写正常输入测试,则无法发现能力扩大。实验首先读取合法任务,再逐项拒绝反例,使接口可用性与边界同时可观察。

练习与验收

给文档名称加入换行、编码异常和超长文本,记录名称校验与内容校验分别在哪一步拒绝。不要为了让所有错误使用同一句话而删除内部错误类别;对外响应与内部诊断可以分层。

再选择一个真实外部命令,只开放一项必要操作,测试以短横线开头的参数、空参数和失败退出码。验收需要说明目标程序如何解释参数,不能仅凭“没有使用 shell”宣布所有命令注入问题已解决。

输入控制点 允许范围 反例
文档名称 私有目录中的单层已知格式 …/和绝对路径
命令执行 固定程序与独立参数 shell片段
动态操作 显式名称映射 任意public_send
数据加载 基础数据类型与字段约束 任意类对象恢复

参考资料

实验附件

下载完整实验。原始运行输出保存在仓库 `writing-plans/ruby/evidence/38/run.log`。使用 Ruby 3.4.11 从仓库根目录执行正文命令;附件与仓库实验内容一致。