当前位置: 首页 > 后端技术 > Node.js

保护Node.js项目的源代码

时间:2023-04-03 23:11:38 Node.js

SaaS(SoftwareasaService,软件即服务),是一种通过互联网提供软件服务的模式。服务提供商将全权负责软件服务的构建、维护和管理,将客户从这些繁琐的工作中解放出来。对于许多中小企业而言,SaaS是采用先进技术的最佳方式。但是,对于大企业来说,情况就不同了。考虑到产品定制化、功能稳定、掌握自己的数据资产,即使成本增加,他们也更愿意将相关服务部署在企业自己的硬件设备上,也就是常说的私有化部署。在私有化部署的过程中,服务商首先要保证自己的源代码不被泄露,否则产品可以被随意复制更改,得不偿失。在传统的后端运行环境中,如Java、.NET等,源代码先编译后部署到服务器运行,不存在泄露风险。对于越来越广泛使用的Node.js来说,运行的是源代码。即使经过压缩和混淆,也可以在很大程度上恢复。本文介绍一种可以在Node.js端使用的代码保护方案,让Node.js项目也可以放心的私下部署。原理V8在编译JavaScript代码时,解析器会生成抽象语法树,进而生成字节码。Node.js有一个名为vm的内置模块。在创建vm.Script实例时,只需在构造函数中传入produceCachedData属性,并将其设置为true即可得到对应代码的字节码。例如:constvm=require('vm');constCODE='console.log("Helloworld");';//源代码constscript=newvm.Script(CODE,{produceCachedData:true});constbytecodeBuffer=script.cachedData;//字节码,这个字节码可以在没有源代码的情况下运行:constanotherScript=newvm.Script(''.repeat(CODE.length),{cachedData:bytecodeBuffer});anotherScript.runInThisContext();//'Helloworld'的代码看起来不是那么好理解,主要体现在创建vm.Script实例时传入的第一个参数:既然源代码的字节码已经在bytecodeBuffer中,为什么要传入第一个范围?为什么要传入与源代码长度相同的空格?首先,在创建vm.Script实例时,V8会检查字节码(cachedData)是否与源代码(作为第一个参数传入的代码)匹配,所以第一个参数不能省略。其次,这个检查很简单,只是比较代码长度是否一致,所以只要使用与源代码长度相同的空格就可以“骗过”这个检查。细心的读者会发现,这样一来,字节码并没有完全脱离源码运行,因为需要源码长度的数据。事实上,还有其他方法可以解决这个问题。试想一下,既然有源码长度校验,那就意味着源码的长度信息也必须保存在字节码中,否则将无法比对。通过查阅V8的相关代码,我们可以发现字节码的header保存了这样的信息://Thedataheaderconsistsofuint32_t-sizedentrys://[0]magicnumberand(internallyprovided)externalreferencecount//[1]versionhash//[2]sourcehash//[3]cpufeatures//[4]flaghash其中item[2]sourcehash是源代码长度。但是因为Node.js的buffer是Uint8Array类型的数组,所以uint32数组中的[2]相当于uint8数组中的[8,9,10,11]。然后提取上述位置的数据:constlengthBytes=bytecodeBuffer.slice(8,12);结果类似于:这是一个字节顺序,称为Little-Endian,低位字节被排出在内存的低地址侧,高位字节放在高地址内存的一侧。为0x0000001b,十进制为27。计算方法如下:firstByte+(secondByte256)+(thirdByte256**2)+(forthByte*256**3)写成代码如下:constlength=lengthBytes.reduce((sum,number,power)=>{returnsum+=number*Math.pow(256,power);},0);//27另外还有一个更简单的方法:constlength=bytecodeBuffer.readIntLE(8,4);//27总之,运行字节码的代码可以优化为:constlength=bytecodeBuffer.readIntLE(8,4);constanotherScript=newvm.Script(''.repeat(length),{cachedData:bytecodeBuffer});anotherScript.runInThisContext();解释完编译文件的原理,我们来尝试编译一个非常简单的工程。目录结构如下:src/lib.jsindex.jsdist/compile.jssrc目录下的两个文件是源代码,内容为://lib.jsconsole.log('我是lib');exports。add=function(a,b){returna+b;};//index.jsconsole.log('我是索引');constlib=require('./lib');console.log(lib.add(1,2));dist目录用于放置编译后的代码。compile.js是执行编译操作的文件,其过程也很简单。读取源文件内容,编译成字节码保存为文件(dist/*.jsc):constpath=require('path');constfs=require('fs');constvm=require('vm');constglob=require('glob');//第三方依赖包constsrcPath=path.resolve(__dirname,'./src');constdestPath=path.resolve(__dirname,'./dist');glob.sync('**/*.js',{cwd:srcPath}).forEach((filePath)=>{constfullPath=path.join(srcPath,filePath);constcode=fs.readFileSync(fullPath,'utf8');constscript=newvm.Script(code,{produceCachedData:true});fs.writeFileSync(path.join(destPath,filePath).replace(/\.js$/,'.jsc'),script.cachedData);});运行nodecompile后,可以在dist目录下生成源码对应的字节码文件,接下来就是运行字节码文件了。但是直接执行nodeindex.jsc是不行的,因为Node.js默认会将目标文件作为JavaScript源代码来执行。在这种情况下,您需要对jsc文件使用特殊的加载逻辑。在dist目录中创建一个新文件main.js,内容如下:constModule=require('module');constpath=require('路径');constfs=require('fs');constvm=require('vm');//加载jsc文件的扩展Module._extensions['.jsc']=function(module,filename){constbytecodeBuffer=fs.readFileSync(filename);constlength=bytecodeBuffer.readIntLE(8,4);constscript=newvm.Script(''.repeat(length),{cachedData:bytecodeBuffer});script.runInThisContext();};//调用字节码文件require('./index');执行nodedist/main,虽然可以加载jsc文件,但是又出现异常信息:ReferenceError:requireisnotdefined这是一个奇怪的问题,在Node.js中,require是一个很基础的功能,怎么可能呢不明确的?原来Node.js会在编译js文件的过程中将其内容打包。以index.js为例,打包后的代码如下:(function(exports,require,module,__filename,__dirname){console.log('我是index');constlib=require('./lib');console.log(lib.add(1,2));});包装这个操作不在编译字节码的步骤,而是在执行之前。因此,需要在compile.js中加入wrapping(Module.wrap)操作:constscript=newvm.Script(Module.wrap(code),{produceCachedData:true});包裹后script.runInThisContext会返回一个函数,执行这个函数运行模块,修改代码如下:Module._extensions['.jsc']=function(module,filename){//省略N行代码constcompiledWrapper=script.runInThisContext();returncompiledWrapper.apply(module.exports,[module.exports,id=>module.require(id),module,filename,path.dirname(filename),process,global]);};再次执行nodedist/main.js,又出现了另一个错误信息:SyntaxError:Unexpectedendofinput这是一个让人一头雾水,不知从何下手的错误。但是仔细观察控制台可以发现,在报错信息之前,打印了两条日志:IamindexIamlib可以看出报错信息是在执行lib.add时产生的。所以得出的结论是函数外的逻辑可以正常执行,但是函数内的逻辑无法执行。回想一下V8编译的流程。在解析JavaScript代码的过程中,Toplevel部分会被解释器完整解析,生成抽象语法树和字节码。NonToplevel部分只是预解析(语法检查),不生成语法树,不生成字节码。NonToplevel部分,即函数体部分,只有在调用函数时才会编译。所以问题一目了然:函数体没有编译成字节码。幸运的是,这种行为也可以改变:constv8=require('v8');v8.setFlagsFromString('--no-lazy');设置no-lazy标志后,执行nodecompile编译,函数体也可以完整解析。最终compile.js代码如下:constpath=require('path');constfs=require('fs');constvm=require('vm');constModule=require('module');constglob=require('glob');constv8=require('v8');v8.setFlagsFromString('--no-lazy');constsrcPath=path.resolve(__dirname,'./src');constdestPath=路径.resolve(__dirname,'./dist');glob.sync('**/*.js',{cwd:srcPath}).forEach((filePath)=>{constfullPath=path.join(srcPath,filePath);constcode=fs.readFileSync(fullPath,'utf8');constscript=newvm.Script(Module.wrap(code),{produceCachedData:true});fs.writeFileSync(path.join(destPath,filePath).replace(/\.js$/,'.jsc'),script.cachedData);});dist/main.js代码如下:constModule=require('module');constpath=require('path');constfs=require('fs');constvm=require('vm');constv8=require('v8');v8.setFlagsFromString('--no-lazy');Module._extensions['.jsc']=function(module,filename){constbytecodeBuffer=fs.readFileSync(f文件名);constlength=bytecodeBuffer.readIntLE(8,4);constscript=newvm.Script(''.repeat(length),{cachedData:bytecodeBuffer});constcompiledWrapper=script.runInThisContext();返回编译包装器。apply(module.exports,[module.exports,id=>module.require(id),module,filename,path.dirname(filename),process,global]);};require('./index');bytenode事实上,如果你真的需要将JavaScript源代码编译成字节码,你不需要自己写那么多代码。npm平台上已经有一个叫做bytenode的包可以做这些事情,而且在细节和兼容性上做得更好。字节码的问题虽然源码编译成字节码后可以得到保护,但是字节码也存在一些问题:JavaScript源码可以在任何系统的Node.js环境中运行,但字节码与运行环境有关。是的,在哪个环境下编译,就只能在那个环境下运行(比如在windows下编译的字节码不能在macOS下运行)。修改源码后,需要重新编译成字节码,比较麻烦。对于一些数据库服务器地址、端口号等配置信息,建议不要编译成字节码,仍然使用源文件运行,方便随时修改。后记作为聪明的读者,你一定能猜到这篇文章是倒序写的。笔者先用bytenode完成需求,再研究其原理。本文同时发表于作者个人博客:《保护 Node.js 项目的源代码》