项目:vitest-playground | 版本:v1.1 | 生成日期:2026-10-06 | 依据:源码分析
本项目的安全命题非常集中且非常尖锐:应用允许陌生浏览器提交任意代码,并在服务器本机用真实 Node 进程执行它。vitestServerRunner.ts 文件头注释直言"此运行器会在本机执行用户代码,仅适用于本地学习工具";AGENTS.md 铁律 2 进一步把边界固化为"不要把它暴露到不可信网络;如需更强隔离应迁移到一次性容器"。本篇系统地回答四个问题:信任边界画在哪里(§1)、谁会以什么方式攻击(§2 威胁模型)、防线如何逐层组织(§3-§4)、防线之外还剩哪些已知风险(§5-§6)。逐条防线的流程视角见《05-核心流程》,接口行为见《03-接口设计》,本篇不重复展开。
先诚实划定边界:本应用没有任何认证、鉴权、会话或用户体系。POST /api/run-vitest 是同源匿名接口,任何人只要能到达该端点就能提交代码执行。信任模型可以概括为一句话:运行器信任"能发起请求的人",防线全部用于约束"一次请求能做什么",而不是"谁在发请求"。这个模型在本地(pnpm dev、局域网教学)是成立的——使用者就是学习者本人;在公网(当前生产形态:NAS Docker 容器 → frpc → VPS nginx,见《06-部署运行》§4.2)则依赖三层缓解:nginx 按 IP 限流(10r/m + burst 3)抬高自动化滥用成本、应用内并发闸门(2 并发/8 排队)兜住资源总量、以及下文所有"受限执行"措施把每次执行的破坏半径压到最小。因此公网部署的真实安全水位是"受限的受控执行 + 流量整形",而不是"隔离的沙箱"——这个定性必须先立住,后面所有的"已防护/未防护"判断都以它为参照。
信任边界的另一侧是数据的保密方向:服务端要保护的是宿主机(文件系统、进程、环境变量中的密钥)与课程数据(隐藏测试源码);攻击者(学习者)的代码本身不设保密——它本来就是用户自己写的。方向明确后,防线清单自然浮现:防宿主被读写、防宿主密钥外泄、防资源被耗尽、防判题器被绕过。
| 攻击面 | 攻击者能力 | 攻击手段示例 | 对应防线 | 残余风险 |
|---|---|---|---|---|
| 文件系统写入 | 可在请求中提交任意"代码内容"(但文件名不可控,见 §4-L3) | 路径穿越写 ../.ssh/authorized_keys、覆盖 vitest.config.js | 文件名白名单(sanitizeEntryName) | 课时代码仍可在沙箱目录内写任意文件(运行期) |
| 文件系统读取 | 测试代码运行在服务器进程派生的子进程中 | fs.readFileSync('/etc/passwd') 并 console.log 回显 | 目录即沙箱 + HOME 指向 runDir;但文件系统整体未隔离 | 子进程用户可读宿主上其运行身份可读的一切文件 ⚠️ |
| 环境变量窃取 | 同上 | process.env 打印回显 | buildChildEnv 白名单,绝不继承 process.env | 白名单内 PATH 若含敏感目录名无实质泄露 |
| 网络出站 | 同上 | 测试里 fetch 外网、扫描内网 | 当前无出网限制(容器化方案 --network none 未落地 ⚠️) | 可作为出站代理/内网探测跳板 |
| 资源耗尽(CPU/内存/磁盘/进程) | 故意死循环、海量输出、海量请求 | for(;;){}、20 万行 console.log、并发轰炸 | 60s 超时 SIGKILL 整组、1MB 输出上限、5MB 请求体、20 文件上限、2/8 并发闸门、nginx 限流 | 磁盘占用在运行期无配额(沙箱目录随删);CPU 在 60s 窗口内可打满 2 核 |
| 判题器绕过 | TDD 课时想不写实现就通关 | 提交把 grader 期望硬编码的实现;伪造课时数据 | hiddenGrader 全量校验;课时数据服务端寻址、请求体白名单三字段 | 查看参考答案运行照样通关(防刷未实现 ⚠️) |
| 隐藏测试窃取 | 想读取 lesson.hidden.spec.* 内容 | 猜文件名读取 | 文件名不在响应路径上;RunResult 全部字段从 vitest JSON 报告派生 | 失败消息可能间接泄露断言细节(设计上可接受,见《05》不变量 4) |
| HTTP 层滥用 | 发送畸形/超大/高频请求 | 超大 body、非法 JSON、旧契约重放 | 405/400 快速失败链、5MB 上限、req.destroy() | 无请求签名,公网可无限重试 4xx(靠 nginx 限流兜底) |
表格中最值得强调的一行是"文件系统读取"与"网络出站":当前方案是进程级受限而非系统级隔离——vitest 子进程以宿主用户身份运行,没有 chroot、没有 seccomp、没有网络命名空间。源码选择这条路线的理由记录在 docs/superpowers/plans/2026-09-28-container-isolation.md:完整隔离交由"一次性 Docker 容器"方案实现(该方案含 Dockerfile、--network none、512M 内存、非 root、只读根文件系统等完整设计,但仅有方案文档,源码无任何实现痕迹 ⚠️ 待落地)。
防线的组织原则是纵深防御:任何单层被绕过,下一层仍然生效;同时每层都被 tests/runner-security.test.ts 的用例钉死(逐用例解析见 §4)。下面按请求经过的顺序从外到内逐层拆解。
L0 反向代理层(nginx,部署配置):limit_req_zone $binary_remote_addr zone=vitest_run:10m rate=10r/m + burst=3 nodelay + limit_req_status 429,把单 IP 的请求速率压到每分钟 10 次;proxy_read_timeout 600s 覆盖应用层最坏排队时间(2 并发消化 8 深队列约 300s,见《03-接口设计》§8 的预算推导)。配置原文在 docs/deploy/runbook.md,属于服务器侧人工项。
L1 请求合法性层(handleRunVitest 快速失败链):方法白名单(仅 POST)、请求体上限(5MB,超限 req.destroy() 主动断开防止继续接收)、JSON 合法性、三字段类型校验(对象非 null 非数组、三值皆 string)、课时存在性校验(findLesson)。全部 400/405,不触碰文件系统与并发槽位。
L2 契约收窄层(防篡改协议):这是本体系最具架构性的一层。请求体只有 chapterKey/lessonKey/code 三个字段——文件表、运行环境、coverage/benchmark 开关、grader 内容等一切执行要素全部由服务端按课时数据推导(buildLessonFiles/resolveRunOptions)。攻击者因此无法指定"把我的代码写到哪个文件""以什么环境跑""要不要关掉隐藏测试"。旧契约 {files, jsdom, coverage, benchmark} 的请求被 400 拒绝并有测试锁定(tests/runner-security.test.ts 的「旧 files 形态请求被 400 拒绝」)。契约演进史见《03-接口设计》§6。
L3 文件名白名单层(sanitizeEntryName):五重检查依次是——SAFE_NAME_RE(/^[A-Za-z0-9][A-Za-z0-9._-]*$/:扁平名、首字符字母数字、无路径分隔符 / 与 \,直接杀死路径穿越与绝对路径);不含 ..;小写化后不在 PROTECTED_NAMES(vitest.config.js、setup.ts、package.json、result.json、tsconfig.json——防止覆盖运行器自身配置或结果文件);小写化后不匹配 VITE_CONFIG_RE(/^(vitest|vite)\.config\./i,任意扩展名的配置文件——Vitest 4 的配置解析覆盖 .ts/.mts/.cts/.js/.mjs/.cjs 六种扩展,仅拦 .js 会漏);必须是字符串。大小写变体(VITEST.CONFIG.JS、Setup.TS、package.JSON)在小写化后比对,兼容不区分大小写的文件系统。校验发生在 runInSandbox 入口、任何写盘之前,非法即抛错——该调用位于 handleRunVitest 的 try 块内,异常落入 500 分支(报文「服务端错误: 非法文件名: …」,见《19》§3.5),而非 400。
L4 沙箱目录隔离层:每次运行在 .vitest-runs/run-<mkdtemp 随机>/ 建独立目录,写盘只发生在该目录内,跑完 finally rm -rf。隔离价值有三:并发请求互不可见(消除旧共享目录方案的竞态与文件互踩);目录必须在项目根之下使 vitest 能向上解析到 node_modules(vitestServerRunner.ts 第 20 行注释);残留为零(测试断言 RUNS_ROOT 为空)。要认清其边界:这是目录级隔离而非文件系统级——子进程仍能访问宿主其他路径(见 §2 威胁表)。
L5 最小环境变量层(buildChildEnv):子进程 env 只含 NODE_ENV:'test'、HOME:<runDir>、LANG(透传,缺省 C.UTF-8)、PATH(透传,缺省 /usr/local/bin:/usr/bin:/bin),Windows 追加 SystemRoot/COMSPEC。绝不继承 process.env 的直接动机是防宿主密钥经测试代码外泄(tests/ 用哨兵变量 RUNNER_SENTINEL 验证);HOME 指向沙箱还让 os.homedir()、dotfile 写入等操作落在随删目录里。
L6 进程层(detached 进程组 + 60s SIGKILL):detached: true 让子进程独立成组,超时用 process.kill(-child.pid, 'SIGKILL') 整组击杀——SIGKILL 不可捕获不可忽略,负 pid 保证 vitest 内部 fork 的 worker 一并死亡,不留孤儿占用 CPU。timeoutMs 参数即便被调用方注入也被 Math.min(timeoutMs, RUN_TIMEOUT) 钳制在 60s 内,测试只能缩短(2000ms 用例)不能放大。SIGKILL 之后清理兜底仍有两层:目录由 finally 删除,runbook 提供 pgrep -af 'vitest.mjs' 与手动清理流程(见《12-FAQ与故障排查》§5)。
L7 输出与资源限额层:stdout/stderr 合并累计上限 1MB(防海量输出打爆宿主内存);output 文本的所有异常路径都截断(尾部 2000 字符);marker 消息截 200 字符、失败消息拼 output 只取前 6 行;文件数上限 20。这些上限共同保证"用户可控的量"都有界。
L8 并发闸门层(createRunLimiter 2/8):模块级信号量限制全局同时在跑 2 个、排队 8 个,超出立即 429「运行排队已满」。所有权转移语义消除微任务间隙超订(机制见《07-模块详解》§1.6)。它是"宁可拒绝不可拖垮宿主机"(源码注释原话)的执行者,也是公网多用户场景下应用层最后的资源阀门。
L9 结果解析免疫层:结果主路径是 --outputFile.json 写出的文件,完全不依赖 stdout——用户在测试里 console.log('{"fake":"json"}') 无法污染解析(测试锁定)。这条防线同时是正确性设计,也是安全设计:它堵住了"通过伪造输出欺骗结果"一类思路。
L10 课程数据保密层:hiddenGrader 只存在于服务端装配产物中,任何响应字段都不包含它;RunResult 的全部字段从 vitest JSON 报告派生(用例标题、状态、失败消息),报告本身不含测试源码。
该文件 363 行、约 30 个用例,是防线的"行为证据"集合。它分两类:纯函数单测(毫秒级、无 IO)与真 spawn vitest 的集成测试(每条 30-60s 预算)。逐 describe 组解析如下(分组清单另见《06-部署运行》§5.2,此处按防线归位):
| describe 组 | 用例 | 验证的防线 | 关键断言 |
|---|---|---|---|
| sanitizeEntryName(白名单) | 放行 8 个合法课时文件名 | L3 | 原样返回(含 lesson.hidden.spec.ts、v1-utils.test.ts) |
| 同上 | 拒绝路径穿越与分隔符(8 个变形) | L3 | ../evil.ts、a/b.ts、C:\evil.ts、..、.、foo/../bar.ts 全抛「非法文件名」 |
| 同上 | 拒绝覆盖运行器配置(10 个变形) | L3 | 含大小写变体 VITEST.CONFIG.JS、package.JSON、任意扩展 vitest.config.mjs |
| 同上 | 拒绝非字符串与空名 | L3 | undefined/null/42/''/{} 全抛 |
| 同上(追加保护) | 拒绝 tsconfig.json | L3 | 构建侧配置单独补入的回归用例 |
| buildChildEnv | 键集合恰为 HOME/LANG/NODE_ENV/PATH | L5 | 哨兵 RUNNER_SENTINEL 不出现;HOME 指向注入值 |
| createRunLimiter(排队) | 满载排队、队满拒绝、释放移交 | L8 | 第 3 次 acquire 抛 RUNNER_BUSY;release 后排队者获得槽位 |
| createRunLimiter(所有权转移) | release 直移交槽位 | L8 | active 全程不超 max;冗余 release 不产生负计数/幻影唤醒 |
| runInSandbox(集成) | 合法用例通过并返回结构化结果 | L4/L6/L9 | success===true、tests[0].status==='passed' |
| 同上 | 父进程环境变量不泄漏进沙箱 | L5 | 子进程内 expect(process.env.RUNNER_SENTINEL).toBeUndefined() 真跑验证 |
| 同上 | 用户 stdout 打印 JSON 不污染解析 | L9 | console.log('{"fake": "json"}') 后仍解析出 noisy 用例 |
| 同上 | 死循环:超时 kill、目录清理 | L4/L6 | 输出含「超时」;readdirSync(RUNS_ROOT) 为空(timeoutMs:2000 缩短) |
| 同上 | 文件数超上限直接失败 | L7 | 21 个文件返回「文件数量超过上限(20)」 |
| 同上(海量 stdout) | 2 万行×200 字符输出不破坏解析 | L7 | 仍 success===true |
| 同上(coverage 回归) | coverage:true 返回覆盖率摘要 | L9 | coverage.total.lines 为 number(修复前恒 undefined 的回归) |
| handleRunVitest(API 层) | 非法 body 400(缺字段/类型错) | L1 | {}、{chapterKey:42,...} 都 400 |
| 同上 | JSON null/数组 body 400 而非 500 | L1 | 原文 'null'、'[]' 直接灌入 |
| 同上(内存 DoS 加固) | 超 5MB 请求体 400 | L1 | 先挂监听再灌 5MB+1 字节,覆盖 destroy 时序 |
| 同上(新契约) | 旧 files 形态 400 | L2 | {files:{...}} 被拒 |
| 同上 | 未知课时 400 | L1 | body 含「未知」 |
| 同上 | code 非字符串 400 | L1 | code: 42 |
| 同上 | 真实课时全链路(basics/first-test) | 全链 | 初始 code 必须跑通,200 + success |
| parseBenchTable | 样例表格解析 / ANSI 剥离解析 / 无行失败 | L9 | name/hz/mean 正确、Summary 段忽略、0 行返回失败 |
| bench 课时全链路 | coverage-advanced/benchmark 经新契约成功 | 全链 | tests.length > 0 |
| 同上 | bench 抛错不误判成功 | L9 | success===false、passed===0 |
测试基建方面有两个值得借鉴的手法:其一,HTTP 层测试用 node:stream 的 PassThrough 伪造 IncomingMessage/ServerResponse(postRequest/collectRes 两个 helper),无需真正起 HTTP 服务器就能验证 400/429/200 全链路,还顺手补齐了 PassThrough 缺失的 setHeader 接口;其二,集成测试直接调用导出的 runInSandbox 而非绕道 HTTP,使"防线行为"与"HTTP 语义"可以分开断言。
docs/deploy/runbook.md 把两件"代码防线管不到"的事制度化。第一件是历史泄露面核查:运行器曾经继承过 process.env(加固前的版本),当前代码已不继承,但历史上运行过的请求可能已经读过当时的进程环境变量——runbook 要求在 /proc/<PID>/environ 里 grep 密钥类条目,并倒查 shell 历史/systemd unit/crontab 里的导密行,发现即轮换。这是一次性的存量核查义务,应记录检查日期。第二件是限流生效验证:6 连 curl 约 4~5 个起出现 429 即为生效。此外 runbook 的故障处理节把"卡死的 vitest 进程""沙箱残留目录"列为巡检项(命令速查见《12-FAQ与故障排查》§5)。生产形态本身的层级(NAS 容器 → frpc → VPS nginx)在《14-配置与环境详解》§9 有配置级展开。
以下是源码确认存在、尚未处理的隐患。列出它们不是否定现有防线,而是给后续演进排优先级(演进路线见《13-技术债务与演进建议》)。
src/pages/vitest-learn/progress.ts 的 saveProgress):localStorage.setItem 在配额溢出时抛 QuotaExceededError。触发链是"每次击键全量写进度 JSON",且 JSON 里含每个课时的完整代码存档(见《10-性能设计与优化》§6)。后果是该次及后续写入失败、控制台报错,极端情况下 handleCodeChange 抛出的异常可能打断 React 事件处理。读取侧有 try/catch(loadProgress),写入侧裸奔,属于不对称防御。src/pages/vitest-learn/chapter.tsx 的 handleRunTest):showSolution 状态下 codeToRun 取 solution,成功后照常 setLessonProgress({status:'passed'})。"参考答案运行不标记 passed"在 docs/superpowers/plans/2026-09-27-runner-hardening.md 被明确列为 Spec 之外 backlog,未实现 ⚠️。影响是成就徽章与进度统计的可信度(防刷缺失),不影响系统安全。--network none 是对应解(方案文档 §Spec 安全目标 2),未落地前这是最大的残余风险面。parseVitestJson 的 exitCode 分支不可达:spawnVitest 的 close 回调未捕获退出码,finishRun 硬编码传 0(vitestServerRunner.ts 约 386-391、410 行)——"进程退出码非 0"提示永远不会出现。属正确性小瑕疵而非安全洞,详见《07-模块详解》§1.4。out 只保留前 1MB,超时/解析失败时附的"尾部 2000 字符"实际取自这 1MB 的尾部——极端场景下真正的报错尾部可能被截掉。缓解:解析主路径不依赖 stdout,仅排障体验受损。结合 AGENTS.md 铁律 2 与上述分析,任何触及 vitestServerRunner.ts、vite.config.ts 挂载层或部署配置的改动,提交前应逐项确认:超时与 SIGKILL 兜底未被削弱(RUN_TIMEOUT 只减不增、Math.min 钳制仍在);PROTECTED_NAMES/VITE_CONFIG_RE/SAFE_NAME_RE 未放宽,新增可配置文件名时先想是否要加入保护名单;env 白名单键集合未扩大(buildChildEnv 的测试断言是精确集合比对,多一个键 CI 就会红);并发/排队/文件数/请求体上限未放宽;结果解析主路径仍是 result.json;handleRunVitest 快速失败链顺序未被打乱(廉价检查仍在前);pnpm test(含 runner-security 全部用例)与 pnpm tsc 全绿;若改动涉及公网行为,同步核对 docs/deploy/runbook.md 的限流与超时预算推导。最后重申架构层的方向性结论(源码注释与 AGENTS.md 一致):更强的隔离应当向前走(一次性容器),而不是向后退(共享目录、放宽白名单)。