React 驱动的自动化脚本平台:实现在 NestJS 后端对前端提交的脚本进行沙箱隔离与权限隔离

嘿,大家好!把安全帽戴好,把代码敲起来。今天我们不讲 Hello World,不讲 CRUD。我们要聊的是一个能让你“手起刀落,代码听命”的终极系统——一个基于 React 和 NestJS 的自动化脚本平台,核心功能是:别让你的用户把你的服务器变成一台洗牌机。

欢迎来到“脚本的伊甸园与地狱”现场。

第一部分:前端——不仅仅是写代码,更是在修仙

首先,咱们得有个地方让用户挥舞鼠标。想象一下,你在 Netflix 上写搜索框,那叫 UI 交互。在这里,用户要写的是逻辑流。这不仅仅是写代码,这就像是在给一个喜怒无常的精灵写咒语。

我们的前端基于 React。为什么要 React?因为它的状态管理能让你的“运行”、“停止”、“暂停”按钮像过山车一样丝滑。我们要实现一个脚本编辑器。这里有个误区:你以为只要放一个 <textarea> 就行?太天真了。

场景: 用户正在写一个脚本,突然他写错了变量名,程序崩溃了,他一脸懵逼。

正确姿势: 我们需要一个带语法高亮、行号显示、自动补全的编辑器。虽然标准的 Monaco Editor(VS Code 的核心)很强大,但为了不扯皮,我们这里用一个轻量级的思路来模拟。

来看看我们的 ScriptEditor 组件。它不仅仅是个输入框,它是个状态监控器

// ScriptEditor.tsx
import React, { useState, useEffect, useRef } from 'react';

interface ScriptEditorProps {
  initialCode: string;
  onRun: (code: string) => void;
  onStop: () => void;
  isRunning: boolean;
}

// 模拟一个简单的语法高亮显示器,实际生产中请直接上 Monaco
const SyntaxHighlighter: React.FC<{ code: string }> = ({ code }) => {
  return (
    <pre style={{ margin: 0, fontFamily: 'monospace', color: '#333' }}>
      <code>{code}</code>
    </pre>
  );
};

export const ScriptEditor: React.FC<ScriptEditorProps> = ({ 
  initialCode, 
  onRun, 
  onStop, 
  isRunning 
}) => {
  const [code, setCode] = useState(initialCode);
  const [status, setStatus] = useState<string>('');
  const [logs, setLogs] = useState<string[]>([]);
  const logContainerRef = useRef<HTMLDivElement>(null);

  // 滚动到底部的小技巧
  useEffect(() => {
    if (logContainerRef.current) {
      logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
    }
  }, [logs]);

  const handleRun = () => {
    if (isRunning) return;
    setStatus('Executing...');
    onRun(code);
  };

  const addLog = (msg: string) => {
    setLogs(prev => [...prev, `> ${new Date().toLocaleTimeString()}: ${msg}`]);
  };

  return (
    <div style={{ border: '1px solid #ccc', borderRadius: '8px', overflow: 'hidden', display: 'flex', flexDirection: 'column', height: '500px' }}>
      <div style={{ background: '#f0f0f0', padding: '10px', borderBottom: '1px solid #ccc' }}>
        <button onClick={handleRun} disabled={isRunning} style={{ marginRight: '10px' }}>
          {isRunning ? 'Running...' : '▶ Execute'}
        </button>
        <button onClick={onStop} disabled={!isRunning} style={{ background: '#ffcccc', color: 'red' }}>
          ⏹ Stop
        </button>
        <span style={{ marginLeft: 'auto', fontSize: '0.9rem' }}>{status}</span>
      </div>

      {/* 编辑区 */}
      <div style={{ flex: 1, padding: '10px', background: '#fff' }}>
        <SyntaxHighlighter code={code} />
      </div>

      {/* 日志区 */}
      <div 
        ref={logContainerRef} 
        style={{ 
          height: '150px', 
          background: '#2d2d2d', 
          color: '#00ff00', 
          padding: '10px', 
          overflowY: 'auto',
          fontFamily: 'monospace',
          fontSize: '0.85rem'
        }}
      >
        {logs.length === 0 && <span style={{ opacity: 0.5 }}>Waiting for execution...</span>}
        {logs.map((log, idx) => <div key={idx}>{log}</div>)}
      </div>
    </div>
  );
};

看到了吗?这不仅仅是个输入框。isRunning 状态控制着按钮,addLog 函数把后端发回来的“剧本”实时翻译给用户看。用户在 React 里写的是“人话”,通过 onRun 传给后端的是“天书”。

第二部分:NestJS 后端——当后端开始当“保姆”

好了,前端把代码扔过来了。现在 NestJS 站到了舞台中央。我们的任务是:别让它跑了,跑出火葬场,或者至少别让它把你的数据库删了。

1. 基础架构与 DTO

首先,我们得定义数据结构。一个脚本不仅仅是代码,它还有元数据。

// script.dto.ts
import { IsString, IsBoolean, IsOptional, IsEnum, IsArray } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

export enum ScriptStatus {
  ACTIVE = 'ACTIVE',
  DISABLED = 'DISABLED',
}

export enum ScriptRole {
  ADMIN = 'ADMIN',     // 能干脏活累活
  USER = 'USER',       // 只能查查数据
  GUEST = 'GUEST',     // 只能跑Hello World
}

export class CreateScriptDto {
  @ApiProperty()
  @IsString()
  name: string;

  @ApiProperty({ example: 'const a = 1; console.log(a);' })
  @IsString()
  code: string;

  @ApiProperty({ enum: ScriptStatus })
  @IsEnum(ScriptStatus)
  status: ScriptStatus;

  @ApiProperty({ enum: ScriptRole })
  @IsEnum(ScriptRole)
  role: ScriptRole; // 权限控制的核心
}

看,这里有个 ScriptRole。这就是我们权限隔离的第一道防线。用户不能随便上传一个带 fs.unlink 的脚本,系统会检查这个脚本的元数据里的 Role。

2. 权限守卫

NestJS 的 Guards 是个好东西。我们要在这里拦截请求。

// auth.guard.ts
@Injectable()
export class ScriptExecutionGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const scriptRole = this.reflector.get<ScriptRole>('role', context.getHandler());
    const userRole = context.switchToHttp().getRequest().user.role; // 假设从JWT里拿用户信息

    // 简单的 RBAC 逻辑
    if (scriptRole === ScriptRole.ADMIN) {
      return true; // Admin 谁的脚本能跑谁都能跑(不过跑完还是要看沙箱脸色)
    }

    if (scriptRole === ScriptRole.GUEST && userRole === ScriptRole.GUEST) {
      return true;
    }

    // 如果用户试图用普通用户的权限去跑管理员脚本 -> 拒绝
    return false;
  }
}

// 使用装饰器
@UseGuards(ScriptExecutionGuard)
@Post('run')
async runScript(@Body() dto: { code: string }) {
  // 这里是危险区域
}

第三部分:核心——沙箱隔离(这是保命的关键)

好了,如果权限守卫放行了,代码就会往下走。但是! 我们不能直接 eval(dto.code)

为什么?因为你的用户可能写了这么一段代码:

// 用户写的代码
const fs = require('fs');
fs.writeFileSync('/etc/passwd', 'hacked');
process.exit(0);

如果你直接运行,恭喜你,服务器变成了肉鸡。我们需要沙箱。

在 Node.js 里,我们有两种主流手段:

  1. Docker 容器化: 就像把每个脚本扔进一个独立的微型 Linux 机器里跑。这是最安全的,但是开销大,有延迟。
  2. VM 沙箱(如 vm2, isolated-vm): 在同一个 Node 进程里,创建一个受限的虚拟机。

作为一个追求极致架构的专家,我建议你组合使用。对于普通的轻量脚本,用 VM 沙箱;对于耗时的、需要系统调用的脚本,用 Docker。

这里我们演示一个“黑名单/白名单”式的沙箱构建器。我们只给脚本提供它需要的工具,其他的统统拿走。

3. 构建安全的执行环境

我们需要一个 ScriptExecutor 服务。

// script.service.ts
import { Injectable, Logger, ForbiddenException } from '@nestjs/common';
import { createRequire } from 'module';
import vm from 'vm';

@Injectable()
export class ScriptService {
  private logger = new Logger('ScriptService');

  // 黑名单:绝对不能允许用户访问的模块
  private readonly BLACKLIST = ['fs', 'child_process', 'http', 'net', 'dns', 'tls', 'child_process'];

  // 白名单:允许暴露给脚本的基础 API
  private readonly ALLOWED_APIS = {
    console: console, // 允许打印,但得拦截一下,不然日志满天飞
    setTimeout: setTimeout,
    clearTimeout: clearTimeout,
    process: {
      env: process.env, // 甚至可以只暴露部分 env
    },
  };

  async executeScript(code: string, userId: string) {
    // 1. 预处理:安全检查(防止用户在代码里注入恶意代码)
    this.validateCodeSanity(code);

    // 2. 创建沙箱上下文
    const sandbox = {
      ...this.ALLOWED_APIS,
      console: {
        log: (...args: any[]) => this.logger.log(`[Script:${userId}] ${args.join(' ')}`),
        error: (...args: any[]) => this.logger.error(`[Script:${userId}] ERROR: ${args.join(' ')}`),
        warn: (...args: any[]) => this.logger.warn(`[Script:${userId}] WARN: ${args.join(' ')}`),
      },
      // 防止用户修改全局对象
      global: this.ALLOWED_APIS,
    };

    // 3. 获取 require 函数(伪装成用户能用的 require)
    const require = createRequire(import.meta.url);

    // 4. 创建 VM 上下文
    const context = vm.createContext(sandbox);

    // 5. 超时机制:防止死循环
    const timeout = 5000; // 5秒超时
    const timeoutId = setTimeout(() => {
      throw new Error('Script execution timeout');
    }, timeout);

    try {
      // 6. 运行代码
      vm.runInContext(code, context, { timeout });

      clearTimeout(timeoutId);
      return { success: true, output: 'Script completed successfully.' };
    } catch (error) {
      clearTimeout(timeoutId);
      this.logger.error(`Script failed for user ${userId}: ${error.message}`);
      throw new ForbiddenException('Script execution failed or was killed.');
    }
  }

  private validateCodeSanity(code: string) {
    // 简单的正则检查,防止明显的恶意注入
    // 注意:正则不是终极防线,真正的防线是 vm.createContext 的隔离性
    if (code.includes('require("fs")') || code.includes('require("child_process")')) {
      throw new ForbiddenException('Disallowed module usage detected.');
    }
  }
}

4. 进阶:Docker 化执行(如果脚本很重)

如果脚本需要上传文件、读写大量的 I/O,上面的 VM 方法就不行了,因为 Docker 是唯一能真正隔离文件系统的方法。这时候,我们需要 NestJS 调用 Docker Daemon。

// docker.service.ts
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { Docker } from 'node-docker';

@Injectable()
export class DockerScriptExecutor {
  private docker = new Docker();

  async runDockerScript(code: string, userId: string) {
    // 创建一个临时的容器
    const container = await this.docker.createContainer({
      Image: 'node:18-alpine', // 使用轻量级镜像
      HostConfig: {
        Memory: 52428800, // 限制内存 50MB
        CpuShares: 512,
        AutoRemove: true, // 运行完自动删,防止堆积
      },
      Cmd: ['node', '-e', code], // 直接在容器里运行代码
    });

    // 启动
    await container.start();

    // 获取日志
    const logs = await container.logs({
      stdout: true,
      stderr: true,
      follow: true,
    });

    // 实时处理日志流... (这里简化了)
    return { status: 'Running', containerId: container.id };
  }
}

第四部分:权限隔离——不仅仅是防火墙

沙箱管的是“脚本能不能做系统操作”,权限隔离管的是“这个脚本属于谁,能干什么”。

1. 数据隔离

在 SQL 中,我们用 WHERE user_id = ? 来隔离数据。在脚本里,我们需要把这个 userId 注入进去。

// 在 NestJS Controller 中注入用户信息
@Post('run')
async runScript(
  @Body() dto: { code: string }, 
  @Request() req // 从 JWT 上下文获取
) {
  // 这里的 userId 是安全的,因为后端验证过身份
  return this.scriptService.executeScript(dto.code, req.user.id);
}

然后在脚本里,代码是这样写的:

// 用户提交的代码片段
// 这里的 db 是我们在沙箱里暴露的一个“安全代理”对象
db.query("SELECT * FROM orders WHERE user_id = ?", [db.currentUser.id]);

2. 资源配额

你不能让脚本无限循环消耗 CPU。我们需要中间件。

// quota.middleware.ts
@Injectable()
export class QuotaMiddleware implements NestMiddleware {
  use(req: any, res: any, next: () => void) {
    // 检查用户是否在冷却期
    const user = req.user;
    const now = Date.now();

    if (user.lastRun && now - user.lastRun < 5000) {
      res.status(429).json({ error: 'Too many requests' });
      return;
    }

    user.lastRun = now;
    next();
  }
}

第五部分:实战中的坑与调试

1. 环境变量泄露

如果你在沙箱里暴露了 process.env,用户就能拿到你的数据库密码。
对策: 深度克隆一份 process.env,或者只暴露 NODE_ENV

2. 脚本崩溃导致宿主进程挂掉

如果代码里有个无限循环,你的 vm.runInContext 会卡死。
对策: 强制超时机制。如果脚本在 5 秒内没返回,强制杀死进程(Docker 里的 SIGKILL 或 VM 里的中断)。

3. 日志管理

如果成千上万用户同时跑脚本,日志会瞬间爆炸。
对策: 使用消息队列(RabbitMQ/Redis Stream)处理日志。脚本只负责输出,后端消费者负责把日志写入 ElasticSearch 或数据库。

第六部分:React 侧的微交互——用户体验的极致

既然是平台,除了能跑,还得好看。我们聊聊如何让前端用户觉得这东西很“牛”。

1. 执行结果的可视化

不仅仅是一行行黑底绿字的日志。我们可以把脚本输出渲染成 HTML,或者图表。

// ResultPanel.tsx
interface ResultPanelProps {
  logs: string[];
  data: any; // 脚本返回的 JSON 数据
}

export const ResultPanel: React.FC<ResultPanelProps> = ({ logs, data }) => {
  return (
    <div style={{ marginTop: '20px', padding: '20px', background: '#f9f9f9', borderRadius: '8px' }}>
      <h4>Execution Result</h4>
      <div style={{ marginBottom: '20px' }}>
        <h5>Logs</h5>
        <div style={{ 
          background: '#222', 
          color: '#0f0', 
          padding: '10px', 
          borderRadius: '4px', 
          fontFamily: 'monospace',
          maxHeight: '200px',
          overflowY: 'auto'
        }}>
          {logs.map((log, i) => <div key={i}>{log}</div>)}
        </div>
      </div>

      {data && (
        <div>
          <h5>Output Data</h5>
          <pre style={{ background: '#eee', padding: '10px', borderRadius: '4px' }}>
            {JSON.stringify(data, null, 2)}
          </pre>
        </div>
      )}
    </div>
  );
};

2. 脚本版本管理

用户可能会改了脚本,结果跑崩了。我们需要历史版本。

// 在 ScriptService 中
async saveScriptVersion(scriptId: string, code: string, userId: string) {
  await this.versionRepo.save({
    scriptId,
    code,
    userId,
    timestamp: new Date()
  });
}

在 React 里,做一个“历史记录”抽屉,点一下回滚代码。

第七部分:架构图解(脑补时刻)

想象一下这个数据流:

  1. 用户 在 React 编辑器敲下 db.save(user)
  2. React 检查语法(简单校验),打包成 JSON 发给 NestJS
  3. NestJS 拦截请求,检查 JWT Token(身份)。
  4. NestJS 检查 Guards(脚本角色是否匹配用户角色)。
  5. NestJS 调用 ScriptService
  6. ScriptService 启动 沙箱(VM 或 Docker)。
  7. 沙箱 执行代码。
    • 如果是 VM:代码试图调用 fs -> 拒绝。
    • 如果是 Docker:代码试图连接外部网络 -> 被防火墙拦截。
  8. 结果 返回给 React,渲染在漂亮的日志面板里。

总结——这就是架构的艺术

构建这个平台,其实就是在玩一场“信任与隔离”的游戏。

  • 前端 是门面,负责把枯燥的代码变得可爱。
  • NestJS 是守门员,负责规则和权限。
  • 沙箱 是笼子,负责物理隔离。

千万别觉得 eval 简单好用。在这个 Web 2.0 甚至 Web 3.0 的时代,代码是最大的安全漏洞。当你允许用户在服务器上写代码时,你就在把自己的命交到了他们手上。

但如果你做好了沙箱,做好了权限隔离,做好了监控,那么恭喜你,你拥有了一个强大的自动化工具。用户负责写逻辑,你负责保证服务器不炸。

所以,拿起你的 NestJS 和 React,去构建一个安全、高效、又酷炫的脚本平台吧!代码在手里,安全在心中。

(完)

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注