Node.js是使用JavaScript构建服务器应用程序的快速增长平台。现在它在生产环境中的使用越来越广泛,Node.js应用程序将开始受到特定的安全漏洞攻击。保护您的用户将需要了解Node.js独有的攻击向量以及与其他Web应用程序平台共享的攻击向量。
第一章,Node.js简介,介绍了Node.js并解释了它与其他开发平台的不同之处。
第二章,一般考虑,介绍了一般的安全考虑,特别是JavaScript本身以及Node.js应用程序的安全考虑。
第四章,请求层考虑,涵盖了特定于请求处理的漏洞,例如跨站请求伪造(CSRF)。
第五章,响应层漏洞,处理了在响应处理期间或之后出现的问题,例如跨站脚本(XSS)。
本书旨在帮助开发人员保护其Node.js应用程序,无论他们是已经在生产中使用它,还是考虑将其用于下一个项目。了解JavaScript是前提条件,建议具有一些Node.js的经验,但不是必需的。
在本书中,您将找到许多不同类型信息的文本样式。以下是一些这些样式的示例,以及它们的含义解释。
文本中的代码单词显示如下:“应该注意EventEmitter对象在错误事件方面具有非常特定的行为。”
代码块设置如下:
functionsayHello(name){"usestrict";//enablesstrictmodeforthisfunctionscopeconsole.log("hello",name);}注意警告或重要说明显示在这样的框中。
提示和技巧显示如下。
Node.js是建立在Chrome的JavaScript运行时之上的平台,用于轻松构建快速、可扩展的网络应用程序。Node.js使用事件驱动的、非阻塞的I/O模型,使其轻量高效,非常适合在分布式设备上运行的数据密集型实时应用程序。
该项目始于2009年,是RyanDahl的创意。在那一年的JSConf.eu(欧洲每年举办的会议)上,他做了演讲,改变了JavaScript开发的面貌。他的演讲包括了一个完整的IRC服务器的令人印象深刻的演示,该服务器用大约400行JavaScript编写。在他的演讲中,他概述了为什么开始这个项目,为什么JavaScript成为其中一个重要部分,以及他在服务器编程领域中希望实现的目标,特别是关于我们如何处理输入和输出(I/O)。
那一年晚些时候,npm项目开始了,其目标是管理Node.js应用程序的软件包,并创建一个公开可用的注册表,供Node.js开发人员之间共享代码。截至Node.js的0.6.3版本,npm已经部署并与Node.js一起安装,成为事实上的软件包管理器。
Node.js与其他平台的不同之处在于它如何处理I/O。它使用事件循环与异步I/O相结合,这使得它能够以轻量级的方式实现高并发性。
通常,当程序需要某种外部输入时,它会以同步的方式进行。以下代码行对任何程序员来说应该非常熟悉:
varresults=db.query("SELECT*FROMusers");print(results[0].username);我们在这里所做的一切就是查询SQL数据库中所有用户的列表,然后打印出第一个用户的名字。在查询这样的数据库时,需要采取许多中间步骤,例如:
每个I/O操作都有一个成本,在使用同步I/O的程序中直接支付。在程序可以继续进行之前,可能会有数百万甚至数千万个时钟周期发生。
编写应用程序服务器时,这样的程序一次只能为一个用户提供服务,直到上一个用户的所有I/O和处理完成后,才能为下一个用户提供服务。这是不可接受的,所以最简单的解决方案是为每个传入的请求创建一个新的线程,这样它们可以并行运行。
这就是Apache网页服务器的工作原理,实现起来并不困难。然而,随着同时用户数量的增加,内存使用量也会增加。每个线程都需要操作系统级别的开销,并且这些开销会迅速累积。此外,在这些线程之间进行上下文切换的开销比预期的更加耗时,进一步加剧了问题。
nginx网页服务器使用事件循环来处理进程。通过这样做,它能够同时处理更多的用户,使用更少的资源。事件循环要求将处理的位分解成小块,并在一个单一队列中运行。这消除了创建线程的高成本,来回切换线程之间的开销,并减少了对整个系统的需求。同时,它填补了处理间隙,特别是在等待I/O完成时发生的间隙。
Node.js采用了nginx成功使用的事件驱动模型,并为许多类型的应用程序提供了相同的能力。在Node.js中,所有I/O都是完全异步的,不会阻塞应用程序的其他线程。Node.jsAPI接受函数参数(通常称为“回调函数”)进行所有I/O操作。然后Node.js启动该I/O操作,并让应用程序外的另一个线程进行处理。完成请求的操作后,事件循环被通知,回调函数被调用并返回结果。
和其他平台一样,Node.js有一个API供开发者编写他们的应用程序使用。JavaScript本身缺乏标准库,特别是用于执行I/O。这实际上成为RyanDahl选择JavaScript的原因之一。因为核心API可以从头开始构建,而不需要担心与标准库发生冲突,如果做错了(考虑到JavaScript的历史,这并不是一个不合理的假设)。
那个核心库是最小化的,但它包括了基本的模块。这包括但不限于:文件系统访问、网络通信、事件、二进制数据结构和流。其中许多API虽然不难使用,但在实现上非常底层。考虑一下这个直接来自Node.js网站的“HelloWorld”演示(附加了注释):
Node.js团队选择保持核心库的范围有限,让开发者社区为其他所有内容创建他们所需的模块,比如数据库驱动程序、单元测试、模板和核心API的抽象。为了帮助这个过程,Node.js有一个叫做npm的包管理器。
npm是处理Node.js应用程序安装依赖项的工具。它选择本地捆绑的依赖项,而不是使用单一的全局命名空间。这允许不同的项目拥有自己的依赖项,即使这些项目之间的版本不同。
下载示例代码
除了允许使用第三方模块外,npm还使得向注册表贡献成为公开的事务。将模块添加到注册表就像执行一个简单的命令一样,使得进入门槛极低。如今,npm注册表上列出了超过42,000个软件包,并且每天都在快速增长。
注册表增长如此迅速,显然背后有一个充满活力的生态系统。我个人可以证明,Node.js开发者社区非常友好,极其多产,并且有巨大的热情。
在本章中,我们探讨了Node.js项目本身的历史,并介绍了开发环境和社区的背景。在下一章中,我们将首先查看JavaScript语言本身的安全功能。
构建安全的Node.js应用程序将需要理解它所构建的许多不同层次。从底层开始,我们有定义JavaScript组成的语言规范。接下来,虚拟机执行你的代码,并且可能与规范有所不同。在此之后,Node.js平台及其API在操作上有细节会影响你的应用程序。最后,第三方模块与我们自己的代码交互,并且需要进行安全编程实践的审计。
首先,JavaScript的官方名称是ECMAScript。国际欧洲计算机制造商协会(ECMA)在1997年首次将这种语言标准化为ECMAScript。这个ECMA-262规范定义了JavaScript作为一种语言的组成,包括它的特性,甚至一些它的错误。甚至一些它的一般古怪之处在规范中保持不变,以保持向后兼容性。虽然我不会说规范本身是必读的,但我会说它是值得考虑的。
其次,Node.js使用Google的V8虚拟机来解释和执行你的源代码。在为浏览器开发时,你需要考虑所有其他虚拟机(更不用说版本了),以及可用的功能。在Node.js应用程序中,你的代码只在服务器上运行,因此你有更多的自由,并且可以使用V8中可用的所有功能。此外,你还可以专门为V8引擎进行优化。
接下来,Node.js处理设置事件循环,并且它会接受你的代码来注册事件的回调并相应地执行它们。在开发应用程序时,你需要注意Node.js对异常和其他错误的响应的一些重要细节。
Node.js之上是开发者API。这个API主要用JavaScript编写,允许你作为JavaScript开发者自己阅读它,并理解它的工作原理。有许多提供的模块可能会被你使用,了解它们的工作原理对你来说很重要,这样你就可以进行防御性编码。
最后,npm提供给你访问的第三方模块数量众多,这可能是一把双刃剑。一方面,你有很多选项可以满足你的需求。另一方面,拥有第三方代码可能是一个潜在的安全责任,因为你需要支持和审计每一个这些模块(以及它们自己的依赖项)以寻找安全漏洞。
JavaScript本身最大的安全风险之一,无论是在客户端还是现在在服务器端,就是使用eval()函数。这个函数,以及类似它的其他函数,接受一个字符串参数,它可以表示一个表达式、语句或一系列语句,并且会像其他JavaScript源代码一样被执行。这在下面的代码中有所展示:
//thesevariablesareavailabletoeval()'dcode//assumethesevariablesareuserinputfromaPOSTrequestvara=req.body.a;//=>1varb=req.body.b;//=>2varsum=eval(a+"+"+b);//sameas'1+2'这段代码可以完全访问当前作用域,甚至可以影响全局对象,给它带来了令人担忧的控制权。让我们看看相同的代码,但想象一下如果有人恶意发送任意的JavaScript代码而不是一个简单的数字。结果如下所示:
vara=req.body.a;//=>1varb=req.body.b;//=>2;console.log("corrupted");varsum=eval(a+"+"+b);//sameas'1+2;console.log("corrupted");由于这里eval()的滥用,我们正在目睹一次“远程代码执行”攻击!当直接在服务器上执行时,攻击者可能会访问服务器文件和数据库。eval()有一些情况下可能会有用,但如果用户输入涉及到任何步骤,那么最好尽量避免使用!
JavaScript还有其他与eval()功能等效的功能,除非绝对必要,否则也应该避免使用。首先是Function构造函数,它允许你从字符串创建一个可调用的函数,如下面的代码所示:
//createsafunctionthatreturnsthesumof2argumentsvaradder=newFunction("a","b","returna+b");adder(1,2);//=>3虽然与eval()函数非常相似,但并非完全相同。这是因为它无法访问当前范围。但是,它仍然可以访问全局对象,并且在涉及用户输入时应避免使用。
如果发现自己处于需要执行涉及用户输入的任意代码的情况下,确实有一个安全选项。Node.js平台的API包括一个旨在让您能够在沙盒中编译和运行代码的vm模块,以防止操纵全局对象甚至当前范围。
应该注意,vm模块存在许多已知问题和边缘情况。您应该阅读文档,并了解您所做的一切可能带来的影响,以确保您不会措手不及。
ECMAScript5对JavaScript进行了广泛的更改,包括以下更改:
严格模式改变了JavaScript代码在某些情况下的运行方式。首先,它会在以前是静默的情况下抛出错误。其次,它会删除和/或更改使JavaScript引擎优化变得困难或不可能的功能。最后,它禁止了一些可能出现在未来版本JavaScript中的语法。
此外,严格模式仅适用于选择加入,并且可以全局应用或应用于单个函数范围。对于Node.js应用程序,要全局启用严格模式,请在执行程序时添加-use_strict命令行标志。
在处理可能使用严格模式的第三方模块时,这可能会对整个应用程序产生负面影响。话虽如此,您可能会要求第三方模块的审核符合严格模式的要求。
通过在函数开头添加"usestrict"指示符,可以启用严格模式,在任何其他表达式之前,如下面的代码所示:
functionsayHello(name){"usestrict";//enablesstrictmodeforthisfunctionscopeconsole.log("hello",name);}在Node.js中,所有所需的文件都包装在一个处理CommonJS模块API的函数表达式中。因此,您可以通过简单地将指令放在文件顶部来为整个文件启用严格模式。这不会像在浏览器等环境中那样全局启用严格模式。
首先,在严格模式下,通过eval()运行的脚本无法向封闭范围引入新变量。这可以防止在运行eval()时泄漏新的可能会与现有变量冲突的变量,如下面的代码所示:
"usestrict";eval("vara=true");console.log(a);//ReferenceErrorthrown–adoesnotexist此外,通过eval()运行的代码无法通过其上下文访问全局对象。这与其他函数范围的更改类似,不过稍后将对此进行解释,但对于eval()来说,这是特别重要的,因为它不能再使用全局对象执行其他黑魔法。
事实证明,eval()函数可以在JavaScript中被覆盖。可以通过创建一个名为eval的新全局变量,并为其分配其他内容来实现。严格模式禁止了这种操作。它更像是一个语言关键字而不是一个变量,尝试修改它将导致语法错误,如下面的代码所示:
//alloftheexamplesbelowaresyntaxerrors"usestrict";eval=1;++eval;vareval;functioneval(){}接下来,函数对象更加安全。ECMAScript的一些常见扩展为每个函数添加了function.caller和function.arguments引用,这些引用在函数调用后出现。实际上,您可以通过遍历这些特殊引用来“遍历”特定函数的调用堆栈。这可能会暴露通常看起来超出范围的信息。严格模式只是在尝试读取或写入这些属性时抛出TypeError备注,如下面的代码所示:
"usestrict";functionrestricted(){restricted.caller;//TypeErrorthrownrestricted.arguments;//TypeErrorthrown}接下来,在严格模式下移除了arguments.callee(例如前面显示的function.caller和function.arguments)。通常,arguments.callee指的是当前函数,但这个神奇的引用也暴露了一种“遍历”调用堆栈的方式,可能会揭示以前隐藏或超出范围的信息。此外,这个对象使得某些优化对JavaScript引擎来说变得困难或不可能。因此,当尝试访问时,它也会抛出TypeError异常,如下面的代码所示:
"usestrict";functionfun(){arguments.callee;//TypeErrorthrown}最后,使用null或undefined作为上下文执行的函数不再将全局对象强制转换为上下文。这适用于之前看到的eval(),但更进一步地阻止了在其他函数调用中对全局对象的任意访问,如下面的代码所示:
"usestrict";(function(){console.log(this);//=>null}).call(null);严格模式可以帮助使代码比以前更加安全,但ECMAScript5也通过属性描述符API包括了访问控制。JavaScript引擎一直具有定义属性访问的能力,但ES5包括了这些API,将同样的权力赋予应用程序开发人员。
对象属性具有以下三个隐藏属性,确定对它们可以进行哪些变化:
在使用对象字面量或赋值定义对象属性时,这是最常见的方法,这三个隐藏属性的默认值都是true。这使得属性在各个方面完全开放修改。然而,有一些新函数允许应用程序开发人员自行设置这些属性,限制对某些对象属性的访问。属性描述符API是完全自选的,即使在ES5中,对象属性的默认行为也不会改变。
首先,Object.defineProperty()函数允许您在指定的对象上指定单个属性及其访问器描述符。它接受三个参数:目标对象、新属性的名称和前面提到的描述符对象。访问器描述符只是一个包含指定属性的对象,这些属性对应于前面列出的属性。
varo={};//thenext2statementsarecompletelyidenticalinresulto.a="A";Object.defineProperty(o,"a",{writable:true,enumerable:true,configurable:true,value:"A"});这两个语句具有相同的结果,后者更加冗长。然而,传统赋值不能影响任何描述符,与后者不同。让我们看看创建“锁定”属性需要什么:
varo={};Object.defineProperty(o,"a",{value:"A"});我们刚刚做的是创建了一个不能被写入、删除或枚举的属性,使其不可变。这允许应用程序开发人员控制数据访问,即使在各种代码边界之间共享对象。
访问器描述符提供的最后一个功能是允许开发人员为特定属性创建getter和setter函数。getter是一个在访问属性时返回数据的函数,setter存储通过赋值发送的数据。以下是示例代码:
varperson={firstName:"Dominic",lastName:"Barnes"};Object.defineProperty(person,"name",{enumerable:true,get:function(){returnthis.firstName+""+this.lastName;},set:function(input){varnames=input.split("");this.firstName=names[0];this.lastName=names[1];}});console.log(person.name);//=>"DominicBarnes"这段代码创建了一个包含来自同一对象上的两个其他属性的数据的属性,并且是动态计算的。在许多情况下,可以使用函数来实现相同的效果,但这样可以更好地分离这两个操作,而不需要在对象本身上使用两个单独的函数。
下一个函数Object.defineProperties()类似。然而,这个函数只接受两个参数,宿主对象和另一个对象,该对象是多个属性的哈希,其中属性值都是访问器描述符。以下是示例代码:
这个函数只接受两个参数,新对象的原型(或null以完全不继承)和一个属性对象,就像我们在Object.defineProperties()中使用的那样,如下面的代码所示:
varconstants=Object.create(null,{PI:{enumerable:true,value:3.14},e:{enumerable:true,value:2.72}});通过将原型设置为null,而不是其他对象,我们创建了一个完全普通的对象,它不继承任何东西,甚至不继承自Object.prototype对象。这是可取的,因为即使对Object.prototype的修改(这本来就是一个坏主意)也不会对使用这种方法创建的对象产生不利影响。
还有一些其他特殊的函数用于改变对象的可访问性。首先是Object.preventExtensions()函数,它防止向指定的对象添加新属性,如下面的代码所示:
varo={a:"A",b:"B",c:"C"};o.d="D";//worksasexpectedObject.preventExtensions(o);o.e="E";//willnotwork正如你所看到的,这允许你配置一个对象,以便其他人无法在你的对象上创建额外的属性。如果在混合中包括严格模式,最后的赋值将抛出错误,而不是悄无声息地失败。另外,应该注意的是,这个操作一旦发生就无法逆转。
接下来是Object.seal()函数,它接受一个对象,并防止属性被删除,除了Object.preventExtensions()函数的效果。换句话说,这将获取所有现有属性,并将它们的可配置属性设置为false。
varo={a:"A",b:"B",c:"C"};deleteo.c;//worksasexpectedObject.seal(o);deleteo.b;//willnotwork这很强大,因为我们可以保留对象的结构,但仍然允许属性值发生变化。与之前一样,这个操作是不可逆的。此外,添加严格模式会导致抛出异常,而不是允许操作悄无声息地失败。
最后是其中最强大的Object.freeze()函数。这个函数应用了与Object.seal()相同的效果,并完全锁定了所有属性。没有值可以被改变(即所有可写属性都设置为false),并且属性描述符都是不可修改的。这使得对象实际上是不可变的,并阻止所有其他尝试改变对象的任何操作,如下面的代码所示:
varo={a:"A",b:"B",c:"C"};//worksasexpectedo.a=1;deleteo.c;Object.freeze(o);//willnotworko.a="A";deleteo.b;冻结对象与其他操作一样,是不可逆转的。在严格模式下,任何尝试写入或更改对象的操作都会引发错误。
跟踪我们在这里讨论的所有事情可能会让人不知所措。当一个团队的人在同一个项目上工作时,问题会变得更加复杂。执行静态分析的工具会获取你的源代码(而不是执行它),并检查你可以配置的特定代码模式。
例如,你可以配置JSHint禁止使用eval()并要求所有函数使用严格模式。通过让它检查你的源代码,当违反这些规则时,它会提醒你。这可以与版本控制结合使用,以防止不安全的代码被添加到项目的代码库中。此外,它也可以在发布之前使用,以确保所有代码在进入生产环境之前都是安全的。
JSHint是JSLint项目的社区驱动分支。JSLint持有主观意见,不像许多人所期望的那样可配置,因此创建了JSHint来填补这一空白。两者都是很好的工具,我强烈建议你为你的JS项目采用其中之一。虽然静态分析不会捕捉一切,但它将通过自动化帮助确保代码的更高质量。
JavaScript语言内置了异常作为错误处理的构造。当抛出异常时,需要一些代码来检测错误并适当处理。然而,如果异常未被捕获,它将触发一个致命的错误。
在浏览器中,未捕获的异常会立即停止任何执行。这不会导致网页崩溃,但有可能使应用程序处于不稳定的状态。
在Node.js中,未捕获的异常将终止应用程序线程。这与其他服务器端编程语言(如PHP)非常不同,那里类似的错误只会导致单个请求失败。现在,你必须应对整个服务器和应用程序被突然停止的情况。
你可以采取的第一步是确保以一种预期和可预测的方式抛出错误,以便以后能够有效地捕获。在Node.js中,使用回调进行异步操作的惯例是将一个Error对象作为第一个参数发送给回调函数。这是Node.js核心使用的标准惯例,并且已被社区广泛采用。
varfs=require("fs");fs.readFile("/some/file","utf8",function(err,contents){//errwillbe...//nullifnoerrorhasoccurred…or//anErrorobjectwithinformationabouttheerror});上述代码只是将一个文件读取为字符串。这个操作有一个回调,接受两个参数。第一个是一个Error对象,但只有在这个I/O操作期间发生错误时才会有,比如文件不存在。通过简单地将错误对象作为函数参数传递,这在技术上并不会"抛出"异常。你的应用程序仍然应该处理这些错误,如果可能的话进行纠正。如果发生意外错误,或者无法直接纠正,你应该自己抛出错误,而不是悄悄地吞噬错误,为自己以后创建难以调试的场景。
Node.js核心有一个广泛使用的实用对象叫做EventEmitter。这是一个可以实例化或继承的对象,允许绑定和发出异步操作的事件。当EventEmitter对象遇到错误时,惯例是使用Error对象作为参数发出一个错误事件。
应该注意,EventEmitter对象在处理error事件时有非常特定的行为。如果你有一个EventEmitter对象发出了一个error事件,但没有附加的监听器来响应这个事件,那么相应的Error对象会被抛出,并且很可能成为一个未捕获的异常。这意味着任何未处理的错误事件都会导致应用程序崩溃,所以在使用EventEmitter对象时,始终要绑定一个error事件处理程序。
当发生未捕获的异常时,Node.js将打印当前堆栈跟踪,然后终止线程。所有Node.js应用程序都可以使用一个名为process的全局对象。它是一个带有特殊事件"uncaughtException"的EventEmitter对象,当未捕获的异常被带到主事件循环时会被触发。通过绑定到此事件,您可以设置自定义行为,例如发送电子邮件或写入特殊的日志文件。以下代码中可以看到这一点:
process.on("uncaughtException",function(err){//we'rejustexecutingthedefaultbehavior//butyoucanimplementyourowncustomlogichereinsteadconsole.error(err);console.trace();process.exit();});在前面的代码中,我只是简单地做了Node.js默认的事情。如我之前提到的,您可以实现自己的错误记录程序。如果您使用自定义处理程序,需要确保通过process.exit()函数自行终止进程。
虽然在发生未捕获的异常后继续应用是可能的,但不建议这样做!根据定义,未捕获的异常中断了应用程序的正常流程,使其处于不稳定和不可靠的状态。如果您简单地忽略错误并继续处理,那么您就会陷入危险的境地。Node.js文档将此视为拔掉计算机的电源来关闭它。您可能可以做几次,但如果这种情况不断重复,系统将变得越来越不稳定和不可预测。
从技术上讲,我们可以使用uncaughtException事件来实现我在这里演示的内容。但是,如果您在应用程序中并行运行多个服务器(例如,一个HTTP服务器和一个WebSocket服务器),或者使用集群模块运行多个进程,那么该事件处理程序不一定会给您处理特定于遇到错误的服务器的上下文。事实上,您甚至无法区分uncaughtException事件中的不同请求,因为该上下文也会丢失。使用域,您可以更优雅地处理错误,而不会丢失上下文。
说到这一点,事情可能会出错。你不应该忽略未捕获的异常,因为你的应用程序会变得不稳定,并且会泄漏引用和内存。处理未捕获异常的唯一安全方式是停止该进程。这意味着你的服务器将无法提供给其他用户使用。这意味着,如果一个恶意用户能够找到一种方法在你的服务器上触发未捕获的异常,他们实际上正在对其他用户发起拒绝服务攻击。
解决方案是拥有一个可以监视你的应用程序进程并在停止时自动重新启动它的进程监视器。有很多选择,包括一些特定于平台的选项。一些可用的进程监视器包括forever、mon和upstart。关键是你应该实现某种进程监视,这样当出现问题时就不必手动重新启动你的应用程序。
一旦你有了一个进程监视器,一定要配置它将错误记录在某个地方,这样你就可以跟踪,以便纠正应用程序中的有害和致命错误。监视你的应用程序崩溃频率,并尽快纠正错误也是明智的。
正如之前提到的,Node.js最大的特点之一是其充满活力的社区和快速增长的模块注册表。因为Node.js核心API故意保持小而集中,你可能会整合其他模块,这样你就不必从头开始编写很多东西。
就像你会努力审查你的代码以确保安全实践一样,你也应该积极参与监控你在项目中包含的npm模块。npm上有许多完全开源的项目,通常可以在GitHub或其他类似的在线资源上找到。这使得手动查看源代码以寻找突出问题变得很容易。作为最后的手段,你可以检查npm在安装依赖时下载的本地包,尽管不能保证获得包的开发环境中的所有内容。
Node.js开发人员在他们的模块中注重的一个重点是使它们小巧、高度集中和可组合(即它们很容易与其他模块互操作)。因此,它们通常在代码行数和复杂性方面非常小,这使得编写安全和可测试的代码变得更加容易。这在涉及应用程序安全时对Node.js平台非常有利。
在本章中,我们研究了适用于JavaScript语言本身的安全功能,包括如何使用静态代码分析来检查前面提到的许多问题。此外,我们还研究了Node.js应用程序的一些内部工作原理,以及在安全性方面与典型的浏览器开发有何不同。最后,我们简要讨论了npm模块生态系统和NodeSecurityProject,该项目旨在为安全目的审计每一个模块。在下一章中,我们将讨论应用程序的安全考虑。
现在是时候处理真实世界的应用程序了!正如之前提到的,Node.js平台的杀手功能之一是丰富的模块和快速发展的社区。审计您使用的每个模块以确保安全仍然很重要,但使用模块很可能会成为工作流程中不可或缺的一部分。
由于其巨大的流行度,我将专门编写我的代码示例以针对Express应用程序。这应该涵盖今天大多数Node.js应用程序,但我们将涵盖的概念适用于任何平台。
Express是一个专注于保持小巧但强大的Node.js最小化Web开发框架。它是建立在另一个称为Connect的框架之上的,这是一个用于编写带有小插件(称为中间件)的HTTP服务器的平台。
Connect和Express的架构允许您仅使用您需要的内容,而不是其他。这非常好地融入了安全讨论中,因为您不会整合大量不使用的功能,这为可能未经检查的安全漏洞敞开了大门。
Connect捆绑了20多个常用的中间件,增加了日志记录、会话、cookie解析、请求体解析等功能。在定义Connect或Express应用程序时,只需按照以下代码添加要使用的中间件:
中间件总是按照附加的顺序执行,这是在确定何时以及何时附加时要记住的事情。
Connect使用传递风格,这意味着每个中间件函数都被赋予控制权,并且在完成后必须将控制传递给继续中的下一个中间件。在我们这里的应用程序方面,每个中间件都被赋予请求和响应对象,并且对请求的生命周期具有完全控制权。
由于它们按顺序执行,让我们来看看这个应用程序的请求/响应循环是如何运作的。由于中间件具有完全控制权,它可以采取以下三种主要行动之一:
幸运的是,我们在这里有所有三个的例子!首先,当一个应用程序进入这个服务器时,它会通过favicon中间件运行。它检查统一资源标识符(URI),如果匹配/favicon.ico,它会为浏览器响应一个favicon图标。如果URI不匹配,它就会简单地传递给下一个中间件。
最后是我们的自定义中间件,这可能是您可能拥有的最简单的中间件。它的作用只是将HelloWorld作为响应主体发送。这意味着无论我们请求什么URI(当然除了/favicon.ico),只要我们提供正确的凭据,我们就会看到HelloWorld。
现在您已经对中间件的工作原理有了基本的了解,让我们继续学习Express以及它对Connect的增强。Express通过Connect系统添加了路由、HTTP助手、视图系统、内容协商和其他功能。事实上,Express应用程序看起来与Connect应用程序非常相似,如下面的代码所示:
varexpress=require('express'),app=express();app.use(express.favicon());app.use(express.basicAuth("username","password"));app.get("/",function(req,res){res.send('HelloWorld');});app.listen(3000);Express自动在其自己的命名空间中包含Connect中间件,因此您可以在不需要显式要求Connect的情况下使用它们。此外,它还添加了一些自己的强大功能,特别是我们在这里使用的路由功能。
Express受到了Ruby的SinatraWeb框架的启发。每个HTTP动词(GET,POST等)在应用对象上都有一个相应的函数。在这里,我们说URL/的HTTPGET请求将发送HelloWorld。任何其他URL都将得到404(未找到)错误,除了/favicon.ico,它由favicon中间件处理。
Express是一种极简主义的方法,可以按照您的意愿开发应用程序。它不会将您锁定在MVC框架或特定的视图引擎中,并允许您包含任何npm模块来为您的应用程序提供动力。
身份验证是确定用户在尝试通过您的应用程序执行某些操作时是否是他们声称的用户的过程。有许多方法可以实现这一点,我将在这里介绍一些更常见的方法。除了一些例外,我的示例将归结为几个可用的npm模块。您可以随时使用其他模块来实现相同的目标。
第一个是HTTP基本身份验证,它是可用的最简单的技术之一。它允许在HTTP请求中提交用户名和密码,并允许服务器在未发送预期凭据时限制访问。
这种方法的主要优点是非常简单实现。事实上,使用Connect可以在一行代码中完成。此外,这种方法完全是无状态的,不需要请求中的任何带外信息。
有一些重要的缺点,首先是它不是保密的。换句话说,基本的HTTP请求包括明文的用户名和密码。从技术上讲,它被编码为base64,但这不是一种加密方法。因此,这种技术必须与某种加密方法结合使用,例如HTTPS。否则,请求可以被数据包嗅探器拦截,凭据就不再是秘密了。
此外,这种方法的效率不太理想。当请求页面需要HTTP基本身份验证时,服务器实际上必须处理第一次请求两次。在第一次尝试中,请求被拒绝,用户需要提供他们的凭据。在第二次尝试中,凭据与请求一起发送,服务器必须再次处理身份验证。根据用户名和密码的验证方式,这可能是每个请求都会产生不可接受的延迟。
在使用中间件时,记住顺序非常重要!确保将身份验证中间件尽早放置在链中,这样你就可以对所有请求进行身份验证,而不是在验证用户身份之前运行不必要的处理。
首先,你可以简单地向中间件提供一个用户名和密码,为你的应用程序提供一个有效的凭据集,如下所示:
app.use(express.basicAuth("admin","123456"));在这里,我们设置了我们的应用程序需要通过HTTP基本认证来要求用户名为"admin",密码为"123456"。这是添加这种认证方法的最简单方法。
更高级的用法是提供一个同步回调函数,可以执行稍微复杂的身份验证方案,例如,你可以包含一个包含用户名和密码组合的JavaScript对象,以便执行内存查找。这在以下代码中有所体现:
varusers={//username:"password"admin:"password",user:"123456"};app.use(express.basicAuth(function(user,pass){returnusers.hasOwnProperty(user)&&users[user]===pass;}));我们已经设置basicAuth来检查我们的users对象是否有相应的用户名和密码组合是有效的。如果回调函数返回true,则认证成功。否则,认证失败,服务器会做出相应的响应。
app.use(express.basicAuth(function(user,pass,done){User.authenticate({username:user,password:pass},done);}));在这个例子中,我们有一个类似的配置,我们使用了一个函数参数。这个函数,与之前的例子不同,有三个参数。它接收用户名和密码,但也接收一个回调函数,当它完成验证凭据时需要执行。出于简洁起见,我没有包括具体的实现细节。
重点是你可以异步执行操作,回调函数有自己的两个参数。按照Node.js的风格,如果认证失败,第一个参数是一个Error对象。第二个参数是用户的信息,将由中间件添加到req.user中,允许后续中间件函数访问用户的信息。
说到底,HTTP基本认证可能对大多数应用程序来说是不够的。接下来,我们将讨论HTTP摘要认证,它最初被设计为HTTP基本认证的继任者。
HTTP摘要认证旨在比HTTP基本认证更安全,因为它不会以明文形式发送凭据。相反,它使用MD5单向哈希算法来加密用户的认证信息。值得注意的是,MD5不再被认为是一种安全的算法,这是这种特定机制的一个缺点。
我只是为了完整起见才包括这个解释。它并不受欢迎,今天很少推荐使用,所以我不会再包括任何更多的细节或例子。
它以与HTTP基本认证相同的方式运作。首先,当需要认证时,客户端的初始请求被拒绝,服务器指示客户端需要使用HTTP摘要认证。客户端计算用户凭据和服务器认证领域的哈希值。根据规范,还有一些可选的功能可用于改进哈希算法并防止被恶意代理劫持。
HTTP摘要认证的一个优点是密码不以明文形式在网络上传输。这种认证方法是在一个时代设计的,在那个时代,对所有网络事务运行HTTPS/SSL是非常昂贵的,无论是在金钱还是处理能力方面。现在那个时代已经过去,你应该在整个应用程序中一直使用HTTPS。在这种情况下,HTTP摘要认证相对于HTTP基本认证的优势几乎不存在。
要在应用程序中使用Passport.js,你需要配置以下三个部分:
Passport.js使用术语“策略”来指代认证请求的一种方法。这可以是用户名和密码,甚至第三方认证,比如OpenID或OAuth。这是你将要配置的第一件事情,它将取决于你选择支持的认证方法。
作为一个起始示例,我们将看一下本地策略,其中你可以接受一个HTTPPOST请求,其中包含身份验证所需的用户名和密码,然后根据以下代码对其进行验证:
//moduledependenciesvarpassport=require("passport"),LocalStrategy=require("passport-local").Strategy;//LocalStrategymeansweperformtheauthenticationourselvespassport.use(newLocalStrategy(//thiscallbackfunctionperformstheauthenticationcheckfunction(username,password,done){//thisisjustamockAPIcallUser.findOne({username:username},function(err,user){//ifafatalerrorofsomesortoccurred,passthatalongif(err){done(err);//ifwedon'tfindavaliduser}elseif(!user||!user.validPassword(password)){done(null,false,{message:"Incorrectusernameandpasswordcombination."});//otherwise,thiswasasuccessfulauthentication}else{done(null,user);}});}));为了简单起见,这不会连接到我们的应用程序,这只是演示了Passport.js中间件的API。我们在这里配置了一个本地策略。这个策略接受一个验证回调,有三个参数:用户名、密码和一个回调函数,一旦认证完成就会被调用。(Passport.js处理从POST请求中提取用户名和密码)回调函数有它自己的三个参数:一个Error对象(如果适用),用户的信息(如果适用,如果认证失败则为false),以及一个选项哈希。
在这种情况下,验证回调调用某种用户API(具体内容并不重要)来查找与提供的用户名匹配的用户,然后继续进行以下检查:
以这种方式使用回调允许Passport.js完全不知道底层实现。现在,让我们继续进行中间件配置步骤。Passport.js专门设计用于在Connect和Express应用程序中使用,但它也适用于使用相同中间件风格的任何应用程序。
配置Passport.js和您的策略后,您需要附加至少一个中间件来在应用程序中初始化Passport.js,如下所示的代码:
会话支持中间件是可选的,但对于大多数应用程序来说是建议的,因为这是一个非常常见的用例,并且必须在Express自己的session中间件之后附加。最后,我们将配置会话支持本身如下所示的代码:
passport.serializeUser(function(user,done){//onlystoretheuser'sIDinthesession(tokeepitlight)done(null,user.id);});passport.deserializeUser(function(id,done){//wecanretrievetheuser'sinformationbasedontheIDUser.findById(id,function(err,user){done(err,user);});});存储所有可用的用户数据,特别是随着并发用户数量的增加,可能会很昂贵。因此,Passport.js为开发人员提供了一种配置存储到会话中的内容以及检索用户数据的能力的方式(而不是在内存中持续保留)。这并不是必需的,因为使用共享数据库存储会话信息可以缓解这个问题。
在上面的例子中,serializeUser函数接收一个回调,当会话被初始化时执行。在这里,我们只将用户的ID存储到会话中,使其尽可能轻量,同时仍然为我们提供查找他们信息所需的信息。
相应的deserializeUser函数在每个后续请求上被调用,并将相应的用户数据添加到请求对象中。在这种情况下,我们使用一个通用API来查找用户,基于他们的ID,并使用该数据发出回调。
OpenID是一种用第三方服务在网络上进行身份验证的开放标准。其目的是允许用户在网络上拥有一个单一的身份,然后可以在许多应用程序中使用,而不需要在每个单独的应用程序中注册。OpenID没有中央管理机构,每个提供商都是独立的,用户可以选择任何他信任的提供商。今天有许多主要的提供商,包括:Google、Yahoo!、PayPal等。
要在应用程序中包含OpenID,我们将使用passport-openid模块。这个模块是Passport.js项目的一流模块,它为您提供了一种实现通用OpenID身份验证过程的策略。首先,让我们看看以下所需的Passport.js配置:
第二个参数是验证回调,只接受两个参数:
我们在这里配置的是使用Passport.js的基本OpenID身份验证实现。现在,我们将继续配置基本的OAuth实现以进行身份验证。
OpenID旨在允许您的身份由受信任的第三方进行身份验证,而OAuth旨在允许用户在不需要向每个单独的方提供凭据的情况下,在不同的应用程序之间共享信息。如果您的应用程序需要与另一个服务共享数据,那么您很可能会从该特定服务中使用OAuthAPI。如果您只需要验证身份,OpenID可能是该服务的首选机制。
OAuth的最佳隐喻是“代客泊车钥匙”。一些豪华汽车配有一把特殊的钥匙,其访问权限受限。我的意思是,这把特殊的钥匙只允许汽车行驶一小段距离,并且只允许代客泊车司机在拥有该钥匙的情况下访问汽车。这与OAuth所实现的非常相似,它允许所有者对他们拥有的资源进行临时和有限的访问,同时不放弃对该资源的完全控制。
通常涉及三方:客户端、服务器和资源所有者。客户端将代表资源所有者向服务器请求资源。
要使用OAuth规范使用的相同真实世界示例,想象一下简已经将一些个人照片上传到一个照片共享网站,并希望通过另一个在线服务将它们打印出来。
为了打印服务(客户端)能够访问存储在照片服务(服务器)中的照片,他们将需要来自Jane(资源所有者)的批准。首先,任何客户端应用程序都需要向任何服务器应用程序注册自己,以获取第一组密钥,即客户端密钥。这些密钥被客户端和服务器都知道,并允许服务器首先验证客户端的身份。
Jane准备好打印她的照片,所以她访问打印服务开始这个过程。她希望从照片服务中获取她的照片,而不是需要将它们上传到另一个服务,所以她告诉打印服务她希望使用照片服务的照片。
现在,打印服务可以使用这个“访问令牌”根据Jane允许的参数从照片服务请求信息,并且可以随时由Jane或照片服务撤销。在下面的示例中,我将坚持使用Facebook模块,该模块使用OAuthv2.0,而不是使用通用的passport-oauth模块。我选择这条路线是为了避免需要展示当今使用的所有OAuth变体,因为每个实现可能都有自己的变体。此外,这里的示例将为您提供足够的PassportAPI介绍,以便您可以将这种方法应用到任何其他提供者。
首先,我们需要安装passport-facebook模块,然后根据以下代码配置Passport.js策略:
接下来,您需要根据以下代码为您的Express应用程序配置路由:
//redirectstheUsertoFacebookforauthenticationapp.get("/auth/facebook",passport.authenticate("facebook"));//FacebookwillredirectbacktothisURLbasedonthestrategyconfigurationapp.get("/auth/facebook/callback",passport.authenticate("facebook",{successRedirect:"/",failureRedirect:"/login"}));这与我们为OpenID设置的路由非常相似,但有一个主要区别。初始路由不是HTML表单POST;它是一个简单的HTTPGET。这意味着您可以设置一个简单的HTML锚点,将它们指向这个路由,如下所示:
许多人最初没有意识到的是,关于express路由的是,您可以在定义路由时传递多个处理程序。它们中的每一个都像任何其他中间件一样,如下面的代码所示:
关键在于,您可以使用多个路由处理程序作为处理预条件的机会,例如检查用户的身份验证状态、他们的角色或关于访问的任何其他规则。其中许多内容高度依赖于您如何构建应用程序以及您如何确定用户可以访问什么。
其次,requireRole中间件对于不熟悉闭包或一级函数的人来说有点复杂。我们在这里做的是返回中间件函数,而不是简单地使用命名函数。通过闭包,我们可以在返回的函数内部访问role参数。这个中间件函数确保经过身份验证的用户具有我们要求的角色。
安全的另一个重要方面是日志记录,或者记录应用程序中的各种事件,以便对异常进行分析。这些异常可以被审查,以便检测攻击者试图绕过安全方法的地方,并且在实际入侵之前检测到这些活动,可以采取进一步的步骤来减轻这些风险。除了安全之外,日志记录还可以帮助检测程序中为用户造成问题的情况,并允许您更轻松地重现和解决这些问题。
除了他们的建议,OWASP还将以下事件作为可选事件呈现:
在确定要存储的日志数据时,OWASP建议避免以下类型的数据:
在某些情况下,以下信息在调查过程中可能有用,但在包含在应用程序日志中之前应仔细审查:
由于每个应用程序和环境都不同,日志记录的方法也可以多种多样。我们将在这里看一下的npm模块旨在提供一个统一的API,可以使用多种不同的方法,同时取决于上下文,允许您同时使用多种方法。
winston模块具有内置传输(也称为核心模块),用于将日志记录到控制台、将日志记录到文件以及通过HTTP发送日志。除了核心模块外,还有官方支持的传输模块,例如CouchDB、Redis、MongoDB、Riak和Loggly。最后,winstonAPI也有一个充满活力的社区,目前有超过23种不同的自定义传输,包括电子邮件传输和各种云服务,如亚马逊的SimpleDB和SimpleNotificationService(SNS)。重点是,您可能需要的任何传输,可能已经有可用的模块,当然您也可以自己编写。
要开始使用winston,请通过npm安装它,然后您可以立即使用“默认记录器”,如下面的代码所示:
varwinston=require('winston');winston.log("info","HelloWorld");winston.info("HelloAgain");这绝对是最快速开始使用winston的方法,但默认情况下只使用控制台传输。虽然默认记录器可以通过更多传输和配置进行扩展,但更灵活的方法是创建自己的winston实例,可以在应用程序中的各种上下文中使用。如下面的代码所示:
varwinston=require("winston");varlogger=new(winston.Logger)({transports:[new(winston.transports.Console)(),new(winston.transports.File)({filename:'somefile.log'})]});在应用程序代码中,我通常将此类模块的样板代码放在它们自己的文件中。从那里,您可以导出一个预配置的对象,可以在整个应用程序中导入和使用,例如,您可以创建一个名为lib/logger.js的文件,看起来像下面的内容:
varpath=require("path"),winston=require("winston");module.exports=new(winston.Logger)({transports:[//onlylogserrorstotheconsolenew(winston.transports.Console)({level:"error"}),//alllogswillbesavedtothisapp.logfilenew(winston.transports.File)({filename:path.resolve(__dirname,"../logs/app.log")}),//onlyerrorswillbesavedtoerrors.log,andwecanexamine//toapp.logformorecontextanddetailsifneeded.new(winston.transports.File)({level:"error",filename:path.resolve(__dirname,"../logs/errors.log")})]});然后在应用程序的其他部分中,您可以包含记录器并轻松使用它,如下所示:
varlogger=require("./lib/logger");logger.log("info","HelloWorld");logger.info("HelloAgain");此外,winston还包括其他高级功能,如自定义日志级别、额外的传输配置和处理未处理的异常。此外,winston并不是Node.js中唯一可用的日志记录API,还有其他可供您考虑的替代方案,具体取决于您自己的需求。更不用说开发自己的定制解决方案来完全控制了。
任何应用程序的重要方面之一是如何处理错误。如前所述,未捕获的异常可能会导致应用程序崩溃,因此能够正确处理错误是开发周期的重要部分。
对自己应用程序中的错误做出响应是关键,因此请参阅第二章,一般注意事项,了解如何处理Node.js中的错误的一般介绍。在这里,我们将专门处理Connect和Express。
首先,在路由处理程序中不要直接抛出错误。虽然Express足够聪明,可以直接在路由处理程序上尝试/捕获错误,但如果您正在执行某种异步操作(这在大多数情况下都是如此),则这对您没有帮助,如下面的代码所示:
app.get("/throw/now",function(req,res){//Expresswrapstheroutehandlerinvocationintry/catch,so//thiswillbehandledwithoutcrashingtheserverthrownewError("Iwillnotcrashtheserver;});app.get("/throw/async",function(req,res){//However,whenperformingsomeasynchronousoperation//time)thenyouwillloseyourserverifyouthrowsetTimeout(function(){//try/catchdoesnotworkoncallbacks/asynchronouscode!thrownewError("IWILLcrashtheserver");},100);});前面两个处理程序都会抛出异常。如前所述,Express将在try/catch中执行处理程序,以处理处理程序本身中抛出的异常。但是,异步代码(例如第二个路由)无法使用典型的try/catch,最终会变成未捕获的异常。简而言之,在处理错误时不要使用throw!
除了传递给处理程序的请求和响应对象之外,还有第三个参数可以像其他中间件一样使用。这通常被称为“next”回调,并且您可以像在中间件中一样使用它,传递给连续中的下一个项目。如下面的代码所示:
app.get("/next",function(req,res,next){//thisisthecorrectwaytohandleerrors,asExpresswill//delegatetheerrortospecialmiddlewarereturnnext(newError("I'mpassedtoExpress"));});如果您使用Error对象作为第一个参数执行下一个回调,那么Connect将接管该错误并委托给您配置的任何错误处理中间件。当您设置一个接受四个参数的中间件时,它总是被视为错误处理中间件。
//4argumentstellsExpressthatthemiddlewareisforerrors//youcanhavemorethan1ifnecessaryapp.use(function(err,req,res,next){console.trace();console.error(err);//justrespondswitha500statuscodeandtheerrormessageres.send(500,err.message);});这个特殊的错误处理中间件放在应用程序堆栈的最后,如果有必要,您可以设置多个。您可以像其他中间件一样通过next传递控制,例如,设置多层错误处理,其中一层可以发送电子邮件,一层可以记录到文件,最后一层可以向用户发送响应。
Connect还有一个特殊的中间件,您可以利用它来处理错误,而无需硬编码自己的中间件。这是errorHandler中间件,当发生错误时,它将自动响应纯文本、JSON或HTML(取决于客户端的标头)。这个中间件表达如下:
app.use(express.errorHandler());通常,这个辅助程序只用于开发,因为您的生产应用程序可能在处理错误时需要更多的工作,您需要完全控制。
总之,始终在路由处理程序中使用“next”回调函数来传达错误,永远不要使用throw。此外,始终通过添加一个带有四个参数的中间件函数来配置某种错误处理中间件。在开发中使用Connect的内置处理程序,并为生产环境设置自己的位置。
一些漏洞出现在应用程序的请求阶段。如前所述,Node.js默认情况下为您做的很少,让您完全自由地构建满足您需求的服务器。
常常在Node.js应用程序中被忽略的一个主要请求处理功能是大小限制。Express(可选)处理请求体数据的缓冲和将请求体解析为有意义的数据结构。当请求仍在被满足时,整个请求体的内容都在内存中。如果不设置限制,恶意用户有多种方法来影响您的系统,例如耗尽内存限制,上传占用不必要磁盘空间的文件。
根据您的需求,您需要确定应用程序的合理限制。虽然您的需求可能不同,但您应该始终设置某种限制,Connect和Express为此目的专门提供了一个中间件,称为limit:
app.use(express.limit("5mb"));此中间件需要尽早添加到堆栈中,否则直到太迟才会被捕获。它需要一个单独的配置,即请求大小的上限。如果发送一个数字,它将被转换为字节数。您还可以发送一个更可读的字符串,例如"5mb"或"1gb"。
如果超出限制,此中间件将响应413(请求实体太大)错误。首先,检查请求的Content-Length标头,如果太大,则直接拒绝请求。当然,标头可能是伪造的,甚至不存在,因此中间件还监视传入数据,如果实际请求体大小达到限制,则触发错误。
bodyParser中间件用于解析特定内容类型的传入请求体。实际上,bodyParser中间件具体来说只是三个不同中间件的简写,即json,urlencoded和multipart。每个中间件对应不同的内容类型。通过限制中间件设置绝对大小是有帮助的,但并不总是足够的。一些请求体应该有不同的限制。
例如,您可能希望允许最多100MB的文件上传。但是,同样大小的JSON将在JSON.parse()函数运行时使您的应用程序停止,因为它是一个阻塞操作。因此,强烈建议除了多部分(因为它处理文件上传)之外,为请求体设置一个更小的限制。
因此,我建议避免使用bodyParser中间件,以便更明确,并允许您为每个子中间件设置不同的限制。
//moduledependenciesvarexpress=require("express"),app=express();//limitingtheallowedsizeofrequestbodies(bycontent-type)app.use(express.urlencoded({limit:"1kb"}));//application/x-www-form-urlencodedapp.use(express.json({limit:"1kb"}));//application/jsonapp.use(express.multipart({limit:"5mb"}));//multipart/form-dataapp.use(express.limit("2kb"));//everythingelse提示像我们在这里讨论的为不同内容类型设置不同限制一样,如果您对中间件的选择顺序不小心,结果可能会出乎意料。
如果首先使用限制中间件,它将导致其他中间件忽略它们自己的大小限制。确保将全局限制中间件放在最后,这样它就可以作为任何其他内容类型的通用处理,而不是由bodyParser中间件系列处理。
Node.js包含一个名为streams的模块,其中包含广泛用于Node.js平台自身核心模块的实现。流很像Unix管道,它们可以被读取,写入,或者根据上下文甚至两者都可以。我不会在这里详细介绍,但流是Node.js的一个杀手功能,您应该尽可能在应用程序和任何npm模块中使用它们。
如果您正在实现更多的RESTfulAPI,例如接受文件上传作为PUT请求,那么在请求处理程序中使用流。以下代码显示了处理将请求体放入文件的低效方法:
varfs=require("fs");//handleaPUTrequestagainst/file/:nameapp.put("/file/:name",function(req,res,next){vardata="",//databufferfilename=req.params.name;//theURLparameterreq.on("data",function(chunk){data+=chunk;//eachdataeventappendstothebuffer});req.on("end",function(){//writethebuffereddatatoafilefs.writeFile(filename,data,function(err){if(err)returnnext(err);//handleawriteerrorres.send("UploadSuccessful");//successmessage});});});在这里,我们将整个请求体缓冲到内存中,然后将其写入磁盘。在小尺寸时,这不是问题,但攻击者可能同时发送许多大型请求体,通过缓冲将自己置于不必要的风险中。在Node.js中,使用流来处理数据是一种长期的方法(谢天谢地,更短的方法也是最好的方法!)。
以下代码是相同请求的示例,只是使用流将数据传送到目的地。
varfs=require("fs");//handleaPUTrequestagainst/file/:nameapp.put("/file/:name",function(req,res,next){varfilename=req.params.name,//theURLparameter//openawritablestreamforouruploadeddatadestination=fs.createWriteStream(filename);//ifourdestinationcouldnotbewrittento,throwanerrordestination.on("error",next);req.pipe(destination).on("end",function(){res.send("UploadSuccessful");//successmessage});});我们的示例设置了一个可写流,表示上传数据的目的地。数据将被直接传送到文件中,而不是在内存中缓冲整个请求体。需要注意的是,这个示例没有正确过滤用户输入;这完全是为了专注于示例的主题,不应直接应用于生产代码。
流是在许多情境下处理数据的一种经过验证和有效的模式,并充分利用了Node.js的事件驱动模型。
当处理许多同时用户,特别是在出现意想不到的交通高峰时,准备好应对灾难情景是很重要的,其中负载变得过重,超出服务器的处理能力。这也适用于缓解拒绝服务(DoS)攻击,这些攻击试图用比服务器可能处理的更多请求来淹没服务器,使其完全崩溃(或者只是减慢到爬行速度)。
这个示例还演示了关于Node.js中事件循环的一个非常重要的观点,值得重复。您的代码与事件循环调度器之间的约定是,所有代码应该快速执行,以避免阻塞事件循环的其他代码。这意味着要避免在应用程序代码中进行CPU密集型计算,不像前面的示例,在其while循环迭代期间阻塞CPU。
Node.js在应用程序主要是I/O绑定时效果最佳,因此应避免CPU密集型操作,比如复杂的计算或非常大的数据集迭代。如果系统需要这样的操作,考虑将阻塞部分作为单独的进程进行分离,以避免占用应用程序的事件循环。
跨站点请求伪造(CSRF)是一种攻击向量,它利用了应用程序对特定用户浏览器的信任。在用户不知情的情况下,应用程序代表用户发出请求,从而使应用程序在假定受信任的用户发出请求的情况下执行某些操作,尽管实际上并非如此。
有许多方法可以实现这一点。一个例子是,一个HTML图像标签(例如,)以某种方式被注入到页面中,无论是合法的还是非法的,比如通过XSS,这是我们将在下一章中讨论的一个漏洞。浏览器隐式地向src属性中指定的URL发送请求,并在HTTP请求的一部分中发送任何cookie。许多跟踪用户身份的应用程序通过包含某种会话标识符的cookie来实现,这样对服务器来说,就好像用户发出了请求。
预防措施非常简单;最常见的方法是要求在修改状态的每个请求中包含一个生成的用户特定令牌。事实上,Connect已经包含了csrf中间件,就是为了这个目的。
它通过向当前用户的会话添加一个生成的令牌来工作,该令牌可以作为一个隐藏的输入字段或任何具有副作用的链接中的查询字符串值包含在HTML表单中。当处理后续请求时,中间件会检查用户会话中的值是否与请求提交的值匹配,如果不匹配,则会失败并返回403(禁止)。
这种方法可以防止攻击者成功发出虚假请求,因为所需的令牌对于每个表单提交都是不同的。
在保护许多攻击向量,比如我们将在下一章中处理的XSS时,重要的是在接收用户输入时对其进行过滤和清理。这发生在Web应用程序的请求阶段,所以我们将在这里进行讨论。一个基本的经验法则是始终验证输入并转义输出。
输入验证有几个目标,首先是验证传入的用户输入是否符合我们应用程序及其工作流程的标准;例如,您可能希望确保用户提交有效的电子邮件地址。我指的不是发送电子邮件进行确认以测试电子邮件地址是否真实,而是确保他们一开始就不输入错误的值。另一个例子是确保数字匹配特定范围,比如大于零。
其次,输入过滤旨在防止不良数据进入系统,可能会损害另一个子系统;例如,如果您接受某个数字输入,然后将其传递给另一个子系统进行一些额外的处理,比如报告或其他远程API。如果您的用户故意或无意地提交其他意外的值,比如符号或字母字符,可能会在未来的操作中造成问题。在很大程度上,计算机是垃圾进,垃圾出,因此我们需要确保我们对任何用户输入都要小心谨慎。
第三,正如之前简要提到的,输入过滤是一种有用的(尽管不完整的)预防措施,可以防止跨站脚本攻击(XSS)等攻击。在HTML、CSS和JavaScript中的XSS攻击存在访问控制的严重问题,这意味着任何脚本都具有与其他脚本相同的访问权限。这意味着如果攻击者能够找到一种方式将进一步的代码注入到您的页面中,他们将拥有很大程度的控制权,这对您的用户可能是有害的。输入过滤可以通过删除可能巧妙嵌入其他用户输入的恶意代码来帮助。
我们的第一个示例将是一个接受各种输入的表单,只是为了尽可能地进行演示。考虑以下HTML表单:
除了验证输入,它还根据以下规则执行一些过滤和转换以进行输出:
根据验证的结果,它要么以403(禁止)的状态响应,并附带验证错误列表,要么以200(OK)的状态响应,并附带过滤后的输入。
这应该表明,向应用程序添加输入验证和过滤非常简单,并且收益是非常值得的。您可以确保数据与各种工作流程的预期格式匹配,并有助于预防性地防范一些攻击向量。
在本章中,我们特别研究了请求漏洞,并提供了一些避免和处理这些漏洞的方法。在下一章中,我们将研究应用程序的响应阶段以及出现的漏洞。
您与用户请求的最后交互当然是响应。这里的讨论将集中在应用程序代码的这一部分的漏洞和最佳实践。这将包括跨站脚本攻击(XSS),一些拒绝服务(DoS)攻击的向量,甚至各种浏览器用于实施特定安全策略的HTTP标头。
跨站脚本攻击(XSS)是处理Web应用程序时的一个更受欢迎的话题,因为在许多方面,这是HTML/CSS/JavaScript的默认行为。具体来说,XSS是一种攻击向量,用于向Web页面注入不受信任且可能恶意的代码。通常,这被视为向您的页面注入JavaScript代码的机会,该代码现在可以访问特定Web页面中客户端几乎可以访问的任何内容。
注入通常来自未经过滤或消毒的用户输入,然后输出到浏览器。考虑以下示例代码:
varexpress=require("express"),app=express();app.get("/",function(req,res){varoutput="";output+='
如果用户输入他们的名字(比如Dominic),一切都很好,用户在下一页上看到"Hello,Dominic"。但是,如果用户输入了其他内容,比如原始HTML呢?在这种情况下,它只是将HTML与我们自己的HTML一起输出,浏览器无法区分。
如果您在该文本字段中输入,那么当您打开下一个页面时,您将看到"Hello,",并且浏览器将触发一个带有"hello!"的警报框。这只是一个无害的例子,但这种漏洞有巨大的潜在危害。这些攻击是通过所谓的不受信任的数据完成的,这些数据可能是原始用户输入,存储在数据库中的信息,或者通过远程数据源访问的信息。然后,您的应用程序使用这些不受信任的数据来构造某种命令,然后执行该命令。当命令被操纵以执行开发人员原始意图之外的某些操作时,危险就会出现。
这种类型攻击的原型示例是SQL注入,其中不受信任的数据用于更改SQL命令。考虑以下代码:
varsql="SELECT*FROMusersWHEREname='"+username+"'";假设用户名变量来自用户输入,重点是它是我们定义的不受信任的数据。如果用户输入了一些无害的东西,比如'Dominic',那么一切都很好,生成的SQL看起来像以下代码:
SELECT*FROMusersWHEREname='Dominic'如果有人输入了一些不那么无害的东西,比如:''OR1=1,那么生成的SQL就会变成以下样子:
SELECT*FROMusersWHEREname=''OR1=1这完全改变了查询的含义,而不是限制为具有匹配名称的一个用户,现在返回了每一行。这可能会更加灾难性,考虑值:'';DROPTABLEusers;,它将生成以下SQL:
SELECT*FROMusersWHEREname='';DROPTABLEusers;没有任何额外的访问权限,用户已经导致了我们应用程序的严重数据损失,可能会使所有用户无法使用整个应用程序。
事实证明,XSS是另一种类型的注入攻击,Web浏览器和它们执行的HTML、CSS和JavaScript都针对这些类型的攻击进行了优化。我们需要了解每种语言中的许多不同上下文。考虑以下模板:
User:<%=username%>
使用我们不信任的数据,我们可以很容易地通过向该值注入额外的HTML来引起麻烦,比如,这将生成以下HTML代码:User:
解决方法是在这个上下文中对任何添加到页面的不受信任的数据使用HTML转义。这种技术将HTML中重要的字符,比如尖括号和引号,转换为它们对应的HTML实体;防止它们改变嵌入其中的HTML结构。以下表格是这种转换的一个例子:这种转义方法使得攻击者更难改变你的HTML结构,这是保护你的网页非常重要的技术。然而,不同的上下文将需要更多的转义技术,我们将很快讨论。
许多流行的模板库默认包括自动的HTML转义,但有些则不包括。这对于选择模板框架或库对你来说应该是一个重要因素。
HTML属性可以被注入其他HTML,用于创建一个新的上下文,比如关闭属性并开始一个新的属性。更进一步,这个注入的HTML可以用来关闭HTML标签,并在另一个上下文中注入更多的HTML。考虑以下模板:
我将在这里展示的最后一个例子是在先前提到的属性中部分URL。使用以下模板:
另一个常见的反模式是直接将JSON数据注入页面,以在渲染页面的同时在服务器和客户端之间共享数据。考虑以下模板:
这种特定的技术,虽然方便,也可能导致XSS攻击。假设serverData对象有一个名为username的属性,反映了当前用户的名字。还假设这个值可以由用户设置,而没有任何过滤,直接在用户输入和页面显示之间(当然不应该发生)。
如果用户将他的名字改为,那么输出的HTML将如下所示:
"};根据HTML规范,字符(即使在JavaScript字符串中,就像我们这里)将被解释为一个闭合标签,攻击者刚刚创建了一个全新的脚本标签,就像任何其他脚本标签一样,它对页面有完全控制权。
与其直接尝试转义JSON数据,减轻这个问题的最佳方法是使用另一种方法来注入你的JSON数据:
接下来,我们使用另一个脚本(最好是外部文件,但绝不是必需的),其中包含查找我们定义的脚本元素并检索其文本内容的代码。通过使用textContent/innerText属性而不是innerHTML,我们得到了浏览器为我们执行的额外转义,以防万一。最后,我们通过JSON.parse运行JSON数据来实际执行JSON解码。
虽然这种方法需要更多的宣传,而且比第一个例子要慢一些,但它会更安全,这是一个很好的权衡。
这些例子绝不是一个详尽的列表,但它们应该说明HTML、CSS和JavaScript各自都有上下文,允许各种类型的代码注入。永远不要相信用户输入,并确保根据上下文使用适当的转义方法。
拒绝服务(DoS)攻击可以采用各种形式,但主要目的是阻止用户访问你的应用程序。一种方法是向服务器发送大量请求,占用服务器的资源,阻止合法请求得到满足。
请求洪水通常针对多线程服务器,比如Apache。这是因为为每个请求生成一个新线程的过程为同时请求的数量提供了一个容易达到的上限。对于Node.js平台的事件循环,这种特定类型的攻击通常不那么有效,尽管这并不意味着它是不可能的。
如果不正确使用事件循环,事件循环仍然可能会暴露应用程序,我无法强调理解它的重要性有多大,同时编写任何Node.js应用程序。你的应用程序代码与事件循环的约定是尽可能快地运行。一次只有一个应用程序的部分在运行,所以CPU密集型也可能占用资源。这适用于所有情况,但我在这一章中提到它是为了特别解决你的响应处理程序。
如前所述,尽可能使用流,特别是在处理网络请求或文件系统时。处理大块数据可能是耗时的,取决于你如何处理这些数据,使用流可以将这些大操作分解成许多小块,从而在过程中满足其他请求。
有一些可用的HTTP标头可以帮助我们的Web应用程序增加一些安全性。我们将看一下一个名为头盔的模块,它被编写为一个Connect/Express中间件的集合,根据您的配置添加这些标头。我们将检查头盔包括的每个中间件函数,以及它们的效果的简要解释。
首先,头盔支持为HTML和Web应用程序的一种新的安全机制设置标头,称为内容安全策略(CSP)。XSS攻击通过使用其他方法欺骗浏览器传递有害内容来规避同源策略(SOP)。
对于支持此功能的浏览器,您可以将资源(例如图像、框架或字体)限制为通过白名单域加载。这通过希望阻止访问不受信任的域加载恶意内容,从而限制了XSS攻击的影响。
CSP通过一个或多个Content-Security-PolicyHTTP标头传达给浏览器,例如:
Content-Security-Policy:script-src'self'此标头将指示浏览器要求所有脚本仅从当前域加载。浏览器检测到来自任何其他域的脚本将被直接阻止。
CSP标头是由分号分隔的一系列指令构成的。实现多个CSP限制的标头示例如下:
Content-Security-Policy:script-src'self';frame-src'none';object-src'none'此标头指示浏览器仅限制脚本到当前域(与我们之前的示例相同),并且完全禁止使用框架(包括iframes)和对象。
每个指令都被命名为*-src,后面跟着一个以空格分隔的预定义关键字列表(必须用引号括起来)或域URL。
可用的关键字包括以下内容:
可用的指令包括以下内容:
省略指令会使其策略完全开放(这是默认行为),除非您指定default-src指令。
头盔可以根据您传递给中间件的配置为每个支持的用户代理(例如浏览器)构造标头。默认情况下,它将提供以下CSP标头:
Content-Security-Policy:default-src'self'这是一个非常严格的策略,因为它只允许从当前域加载外部资源,而不允许从其他任何地方加载。在大多数情况下,这太过严格,特别是如果您要使用CDN或允许外部服务与您自己通信。
您可以通过中间件定义函数来配置头盔,通过添加一个名为defaultPolicy的属性,其中包含您的指令作为对象哈希,例如:
CSP还包括了一个报告功能,你可以用来审计自己的应用程序并快速检测漏洞。有一个专门用于此目的的report-uri指令,告诉浏览器发送违规报告的URI。参考以下示例代码:
Content-Security-Policy:default-src'self';...;report-uri/my_csp_report_parser;当浏览器发送报告时,它是一个具有以下结构的JSON文档:
当刚开始使用CSP时,可能不明智立即设置策略并开始阻止。在详细说明应用程序策略的过程中,你可以设置CSP以尊重报告模式。
这允许你设置完整的策略,而不是立即阻止用户,你可以简单地接收详细违规报告。这为你提供了在实施之前微调策略的方法。
要启用报告模式,你只需更改HTTP头部名称。不再使用我们一直在使用的,而是简单地使用Content-Security-Policy-Report-Only,其他一切保持不变:
Content-Security-Policy-Report-Only:default-src'self';...;report-uri/my_csp_report_parser;在helmet中,通过在配置对象中包含reportOnly参数来启用报告模式:
CSP是一种出色的安全机制,你应该立即开始使用,尽管浏览器支持并不完全。截至本文撰写时,它是W3C候选推荐,预计浏览器将以快速的速度实现这一功能。
Strict-Transport-Security:max-age=15768000这告诉浏览器,大约六个月内,当前域从现在开始应该通过HTTPS访问(即使用户通过HTTP访问)。这是由helmet设置的默认配置,也是最简单的实现方式:
app.use(helmet.hsts());这使用先前说明的配置设置了HSTS的中间件,中间件定义函数还接受两个可选参数。首先,max-age指令可以设置为一个数字(应以秒表示)。其次,includeSubDomains指令可以设置为一个简单的布尔值:
app.use(helmet.hsts(1234567,true));这将设置以下头部:
Strict-Transport-Security:max-age=1234567;includeSubdomains浏览器支持目前并不像CSP那样完整,但预计会朝着这个方向前进。与此同时,将其添加到应用程序的安全详细信息中是值得的。
这个头部控制特定页面是否允许加载到或
这是通过另一个HTTP头部通信给浏览器的,因此当浏览器加载一个框架/iframe的URL时,它将检查这个头部以确定采取的行动。头部看起来像下面这样:
X-Frame-Options:DENY在这里,我们使用值DENY,这是通过头盔配置时的默认值。其他可用选项包括sameorigin,它只允许在当前域上加载域。最后一个选项是allow-from选项,允许您指定可以在框架中呈现当前页面的URI白名单。
在大多数情况下,默认设置应该工作得很好,您可以通过头盔这样设置:
app.use(helmet.xframe());这将添加我们之前看到的标头。要使用sameorigin选项进行配置,请使用以下配置:
helmet.xframe('sameorigin');最后,这将设置allow-from变体,还为您提供了设置允许的URI的第二个参数:
这个下一个标头是特定于InternetExplorer的,它启用了XSS过滤器。而不是我自己解释,这是来自MicrosoftDeveloperNetwork(MSDN)的解释。
这个功能可能默认情况下已启用,但是如果用户自己禁用了它或在某些选择区域禁用了它,可以使用类似以下的简单标头来启用它:
X-XSS-Protection:1;mode=block通过将标头设置为0,强制禁用XSS过滤器,但该配置不通过头盔公开。实际上,它根本没有配置,因此其使用就像这样简单:
app.use(helmet.iexss());X-Content-Type-Options这是另一个标头,它阻止某些浏览器的特定行为(目前只有InternetExplorer和GoogleChrome支持此功能)。在这种情况下,即使资源本身设置了有效的Content-Type标头,浏览器也会尝试“嗅探”(例如,猜测)返回资源的MIME类型。
这可能会导致浏览器被欺骗以执行或呈现开发人员意外的方式的文件,这取决于许多因素可能导致潜在的安全漏洞。关键是您的服务器的Content-Type标头应该是浏览器考虑的唯一因素,而不是试图自行猜测。
与前面的例子一样,没有真正的配置可用,以下标头将简单地添加到您的应用程序中:
X-Content-Type-Options:nosniff通过头盔配置此标头:
app.use(helmet.contentTypeOptions());Cache-Control头盔提供的最后一个中间件是用于将Cache-Control标头设置为no-store或no-cache。这可以防止浏览器缓存给定的响应。这个中间件也没有配置,并且是通过以下方式包含的:
app.use(helmet.cacheControl());您将使用此中间件和标头来防止浏览器存储和缓存可能包含敏感用户信息的页面。然而,这样做的折衷是当在整体应用程序中应用时,您可能会遇到严重的性能问题。
在处理静态文件和资源(例如样式表和图像)时,此标头只会减慢您的站点速度,并且可能不会增加任何安全性好处。确保小心地在整体应用程序中如何以及何处应用此特定中间件。
头盔模块是向您的应用程序添加这些有用的安全功能的快速方法,这是由Connect创建的强大中间件架构启用的。有很多这些安全功能中的许多无法在这里解决,并且可能会在将来发生变化,因此最好熟悉它们所有。
在本章中,我们看到了在应用程序处理的响应阶段出现的漏洞,比如XSS和DoS。我们还研究了如何通过防御性编码或利用更新的安全标准和政策来减轻这些特定问题。