最佳实践推荐 (01) -- Node.js Best Practices

目录

Node.js Best Practices

Node.js Best Practices

Node.js 最佳实践。很多关于Node.js的优秀文章。

官网地址

学习笔记

项目结构实践

组件式构建解决方案

最终的解决方案是开发小型软件:将整个堆栈划分为独立的组件,这些组件不与其他组件共享文件,每个组件由很少的文件构成(例如API、服务、数据访问、测试等),因此很容易理解它。

推荐: 通过独立组件构建解决方案
src
└─components
    ├─orders
    ├─products
    └─users
      └─index.js
      └─usersAPI.js
      └─usersDAL.js
避免: 按技术角色对文件进行分组
src
└─controllers
└─modules
└─models
  └─users.js
  └─orders.js
  └─products.js

应用程序分层,保持Express在其范围内

推荐: 创建并传递定制的上下问对象。
usersAPI.js
1
2
3
4
5
6
7
8
9
router.get('/', (req, res, next) = {
usersDBAccess.getByID(
{
userID: req.user.id
}
);
});

module.exports = router;
usersDAL.js
1
2
3
4
5
class UsersDAL {
getByID(config) {
this.otherFunction(config.userID);
}
}
避免: Express 对象(例如 request, response)传递到业务逻辑层。

这将使得整个系统过渡依赖 Express,并且不可单元测试。

usersAPI.js
1
2
3
4
5
router.get('/', (req, res, next) = {
usersDBAccess.getByID(req);
});

module.exports = router;
usersDAL.js
1
2
3
4
5
class UsersDAL {
getByID(req) {
this.otherFunction(req);
}
}

将公用实用工具封装成 npm 包

无需公开分享 私人模块, 私人注册表本地 npm 包

使用环境感知,安全,分层的配置

配置分环境(例如测试环境,生成环境)。避免存储像密码数据这样的敏感信息。dotenv, config可选用.

错误处理最佳实践

对于异步的错误处理,使用 Async-Await 或者 promises

使用 promises 捕获错误:

1
2
3
4
5
6
7
8
doWork()
.then(doWork)
.then(doOtherWork)
.then((result) => doWork)
.catch((error) => {
throw error;
})
.then(verify);

使用 async/await 捕获错误:

1
2
3
4
5
6
7
8
9
10
11
12
async function executeAsyncTask() {
try {
const valueA = await functionA();
const valueB = await functionB(valueA);
const valueC = await functionC(valueB);
return await functionD(valueC);
} catch (err) {
logger.error(err);
} finally {
await alwaysExecuteThisFunction();
}
}

仅使用内建的错误对象

使用Node.js的内置错误对象有助于在你的代码和第三方库之间保持一致性,它还保留了重要信息,比如StackTrace。当引发异常时,给异常附加上下文属性(如错误名称和相关的HTTP错误代码)通常是一个好的习惯。

推荐:
1
2
3
4
5
6
7
8
9
10
11
12
13
//从典型函数抛出错误, 无论是同步还是异步
if(!productToAdd)
throw new Error("How can I add new product when no value provided?");

//从EventEmitter抛出错误
const myEmitter = new MyEmitter();
myEmitter.emit('error', new Error('whoops!'));

//从promise抛出错误
return new promise(function (resolve, reject) {
Return DAL.getProduct(productToAdd.id).then((existingProduct) => {
if(existingProduct != null)
reject(new Error("Why fooling us and trying to add an existing product?"));
避免:
1
2
3
//抛出字符串错误缺少任何stack trace信息和其他重要属性
if(!productToAdd)
throw ("How can I add new product when no value provided?");
更推荐:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//从node错误派生的集中错误对象
function appError(name, httpCode, description, isOperational) {
Error.call(this);
Error.captureStackTrace(this);
this.name = name;
//...在这赋值其它属性
};

appError.prototype = Object.create(Error.prototype);
appError.prototype.constructor = appError;

module.exports.appError = appError;

//客户端抛出一个错误
if(user == null)
throw new appError(commonErrors.resourceNotFound, commonHTTPErrors.notFound, "further explanation", true)

区分操作型错误和程序设计错误

区分以下两种错误类型将最大限度地减少应用程序停机时间并帮助避免出现荒唐的错误: 操作型错误指的是您了解发生了什么情况及其影响的情形 – 例如, 由于连接问题而导致对某些 HTTP 服务的查询失败问题。另一方面, 程序型错误指的是您不知道原因, 有时是错误不知道来自何处的情况 – 可能是一些代码试图读取未定义的值或 DB 连接池内存泄漏。操作型错误相对容易处理 – 通常记录错误就足够了。当程序型错误出现,事情变得难以应付, 应用程序可能处于不一致状态, 你可以做的,没有什么比优雅的重新启动更好了。

集中处理错误

一个典型的错误处理流程可能是:一些模块抛出一个错误 -> API路由器捕获错误 -> 它传播错误给负责捕获错误的中间件(如Express,KOA)-> 集中式错误处理程序被调用 -> 中间件正在被告之这个错误是否是一个不可信的错误(不是操作型错误),这样可以优雅的重新启动应用程序。

一个典型错误流:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//DAL层, 在这里我们不处理错误
DB.addDocument(newCustomer, (error, result) => {
if (error)
throw new Error("Great error explanation comes here", other useful parameters)
});

//API路由代码, 我们同时捕获异步和同步错误,并转到中间件
try {
customerService.addNew(req.body).then(function (result) {
res.status(200).json(result);
}).catch((error) => {
next(error)
});
}
catch (error) {
next(error);
}

//错误处理中间件,我们委托集中式错误处理程序处理错误
app.use(function (err, req, res, next) {
errorHandler.handleError(err).then((isOperationalError) => {
if (!isOperationalError)
next(err);
});
});

在一个专门的对象里面处理错误:

1
2
3
4
5
6
7
module.exports.handler = new errorHandler();

function errorHandler() {
this.handleError = function (err) {
return logger.logError(err).then(sendMailToAdminIfCritical).then(saveInOpsQueueIfCritical).then(determineIfOperationalError);
}
}

使用接口管理平台对API错误文档化

REST API使用HTTP代码返回结果, API用户不仅绝对需要了解API schema, 而且还要注意潜在错误 – 调用方可能会捕获错误并巧妙地处理它。例如, 您的api文档可能提前指出, 当客户名称已经存在时, HTTP状态409将返回 (假设api注册新用户), 因此调用方可以相应地呈现给定情况下的最佳UX。
工具参考: https://developer.aliyun.com/article/925370#slide-2

特殊情况产生时,优雅地退出服务

杀进程,使用“重启”的工具(像Forever,PM2,等等)重新开始。

决定是否退出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// Assuming developers mark known operational errors with error.isOperational=true, read best practice #3
process.on('uncaughtException', (error: Error) => {
errorManagement.handler.handleError(error);
if(!errorManagement.handler.isTrustedError(error))
process.exit(1)
});

// centralized error object that derives from Node’s Error
export class AppError extends Error {
public readonly isOperational: boolean;

constructor(description: string, isOperational: boolean) {
super(description);
Object.setPrototypeOf(this, new.target.prototype); // restore prototype chain
this.isOperational = isOperational;
Error.captureStackTrace(this);
}
}

// centralized error handler encapsulates error-handling related logic
class ErrorHandler {
public async handleError(err: Error): Promise<void> {
await logger.logError(err);
await sendMailToAdminIfCritical();
await saveInOpsQueueIfCritical();
await determineIfOperationalError();
};

public isTrustedError(error: Error) {
if (error instanceof AppError) {
return error.isOperational;
}
return false;
}
}

export const handler = new ErrorHandler();

使用成熟的logger提高错误可见性

一套实践和工具将有助于更快速地解释错误 – (1)使用不同的级别(debug, info, error)频繁地log;(2)在记录日志时, 以 JSON 对象的方式提供上下文信息, 请参见下面的示例;(3)使用日志查询API(在大多数logger中内置)或日志查看程序软件监视和筛选日志;(4)使用操作智能工具(如 Splunk)为操作团队公开和管理日志语句。
Pino(专注于性能的新库), Winston(非常流行).

使用你最喜欢的测试框架测试错误流

使用 APM 产品发现错误和宕机时间

异常 != 错误。传统的错误处理假定存在异常,但应用程序错误可能以代码路径慢,API停机,缺少计算资源等形式出现。因为APM产品允许使用最小的设置来先前一步地检测各种各样 “深埋” 的问题,这是运用它们方便的地方。APM产品的常见功能包括: 当HTTP API返回错误时报警, 在API响应时间低于某个阈值时能被检测, 觉察到‘code smells’,监视服务器资源,包含IT度量的操作型智能仪表板以及其他许多有用的功能。

捕获未处理的promise rejections

使用一个优雅的回调并订阅到process.on(’unhandledrejection’,callback)是高度推荐的 – 这将确保任何promise错误,如果不是本地处理,将在这处理。

这些错误将不会得到任何错误处理程序捕获(除了 unhandledrejection):

1
2
3
4
5
6
DAL.getUserById(1).then((johnSnow) =>
{
//this error will just vanish
if(johnSnow.isAlive == false)
throw new Error('ahhhh');
});

捕获 unresolved 和 rejected 的 promise:

1
2
3
4
5
6
7
8
9
10
process.on('unhandledRejection', (reason, p) => {
//我刚刚捕获了一个未处理的promise rejection, 因为我们已经有了对于未处理错误的后备的处理机制(见下面), 直接抛出,让它来处理
throw reason;
});
process.on('uncaughtException', (error) => {
//我刚收到一个从未被处理的错误,现在处理它,并决定是否需要重启应用
errorManagement.handler.handleError(error);
if (!errorManagement.handler.isTrustedError(error))
process.exit(1);
});

快速报错,使用专用库验证参数

由于对显式编程和防御性编程是件恼人的事情(比如考虑验证分层的JSON对象,它包含像email和日期这样的字段),我们倾向于避免做这样的事情 – 像Joi这样的库和验证器轻而易举的处理这个乏味的任务。

使用‘Joi’验证复杂的JSON输入:

1
2
3
4
5
6
7
8
9
10
11
12
var memberSchema = Joi.object().keys({
password: Joi.string().regex(/^[a-zA-Z0-9]{3,30}$/),
birthyear: Joi.number().integer().min(1900).max(2013),
email: Joi.string().email()
});

function addNewMember(newMember)
{
//assertions come first
Joi.assert(newMember, memberSchema); //throws if validation fails
//other logic here
}

在 return 之前,总是使用 await 来避免遗漏堆栈跟踪

如果一个异步函数没有等待就返回一个promise(例如调用其他异步函数),如果发生错误,那么调用方函数将不会出现在堆栈跟踪中。

这将使诊断错误的人只得到部分信息。

适当地 call 和 await
推荐:
1
2
3
4
5
6
7
8
9
10
11
async function throwAsync(msg) {
await null // 至少需要 await 一些东西才能真正实现异步
throw Error(msg)
}

async function returnWithAwait() {
return await throwAsync('with all frames present')
}

// 👍 will have returnWithAwait in the stacktrace
returnWithAwait().catch(console.log)

日志:

1
2
3
Error: with all frames present
at throwAsync ([...])
at async returnWithAwait ([...])
避免:
1
2
3
4
5
6
7
8
9
10
11
async function throwAsync(msg) {
await null // 至少需要 await 一些东西才能真正实现异步
throw Error(msg)
}

async function returnWithoutAwait () {
return throwAsync('missing returnWithoutAwait in the stacktrace') // 没有等待 await
}

// 👎 will NOT have returnWithoutAwait in the stacktrace
returnWithoutAwait().catch(console.log)

日志:

1
2
Error: missing returnWithoutAwait in the stacktrace
at throwAsync ([...])
将返回 promise 的函数标记为 async
推荐:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
async function throwAsync () {
await null // 至少需要 await 一些东西才能真正实现异步
throw Error('with all frames present')
}

async function changedFromSyncToAsyncFn () {
return await throwAsync()
}

async function asyncFn () {
return await changedFromSyncToAsyncFn()
}

// 👍 now changedFromSyncToAsyncFn would present in the stacktrace
asyncFn().catch(console.log)

日志:

1
2
3
4
Error: with all frames present
at throwAsync ([...])
at changedFromSyncToAsyncFn ([...])
at async asyncFn ([...])
避免:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
async function throwAsync () {
await null
throw Error('missing syncFn in the stacktrace')
}

function syncFn () {
return throwAsync()
}

async function asyncFn () {
return await syncFn()
}

// 👎 syncFn would be missing in the stacktrace because it returns a promise while been sync
asyncFn().catch(console.log)

日志:

1
2
3
Error: missing syncFn in the stacktrace
at throwAsync ([...])
at async asyncFn ([...])
在将异步回调作为同步回调传递之前,将异步回调封装在一个虚拟异步函数中
推荐:
1
2
3
4
5
6
7
8
9
10
async function getUser (id) {
await null
if (!id) throw Error('with all frames present')
return {id}
}

const userIds = [1, 2, 0, 3]

// 👍 now the line below is in the stacktrace
Promise.all(userIds.map(async id => await getUser(id))).catch(console.log)

日志:

1
2
3
4
Error: with all frames present
at getUser ([...])
at async ([...])
at async Promise.all (index 2)
避免:
1
2
3
4
5
6
7
8
9
10
async function getUser (id) {
await null
if (!id) throw Error('stacktrace is missing the place where getUser has been called')
return {id}
}

const userIds = [1, 2, 0, 3]

// 👎 the stacktrace would include getUser function but would give no clue on where it has been called
Promise.all(userIds.map(getUser)).catch(console.log)

日志:

1
2
3
Error: stacktrace is missing the place where getUser has been called
at getUser ([...])
at async Promise.all (index 2)

编码风格实践

使用 ESLint 和 Prettier

ESLint检查可能的代码错误和修复代码样式,prettierbeautify在格式化修复上功能强大,可以和ESlint结合起来使用。

使用ESLint 插件

除了仅仅涉及 vanilla JS 的 ESLint 标准规则,添加 Node 相关的插件,比如eslint-plugin-node, eslint-plugin-mocha and eslint-plugin-node-security

在同一行开始一个代码块的大括号

推荐:
1
2
3
function someFunction() {
// 代码块
}
避免:
1
2
3
4
function someFunction()
{
// code block
}

不要忘记分号

即使没有获得一致的认同,但在每一个表达式后面放置分号还是值得推荐的。这将使您的代码, 对于其他阅读代码的开发者来说,可读性,明确性更强。

否则: 在前面的章节里面已经提到,如果表达式的末尾没有添加分号,JavaScript的解释器会在自动添加一个,这可能会导致一些意想不到的结果。

命名您的方法

命名所有的方法,包含闭包和回调, 避免匿名方法。当剖析一个node应用的时候,这是特别有用的。命名所有的方法将会使您非常容易的理解内存快照中您正在查看的内容。

变量、常量、函数和类的命名约定

当命名变量和方法的时候,使用 lowerCamelCase ,当命名类的时候,使用 UpperCamelCase (首字母大写),对于常量,则 UPPERCASE

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// for global variables names we use the const/let keyword and UPPER_SNAKE_CASE
let MUTABLE_GLOBAL = "mutable value"
const GLOBAL_CONSTANT = "immutable value";
const CONFIG = {
key: "value",
};

// examples of UPPER_SNAKE_CASE convention in nodejs/javascript ecosystem
// in javascript Math.PI module
const PI = 3.141592653589793;

// https://github.com/nodejs/node/blob/b9f36062d7b5c5039498e98d2f2c180dca2a7065/lib/internal/http2/core.js#L303
// in nodejs http2 module
const HTTP_STATUS_OK = 200;
const HTTP_STATUS_CREATED = 201;

// for class name we use UpperCamelCase
class SomeClassExample {
// for static class properties we use UPPER_SNAKE_CASE
static STATIC_PROPERTY = "value";
}

// for functions names we use lowerCamelCase
function doSomething() {
// for scoped variable names we use the const/let keyword and lowerCamelCase
const someConstExample = "immutable value";
let someMutableExample = "mutable value";
}

使用const优于let,废弃var

使用const意味着一旦一个变量被分配,它不能被重新分配。使用const将帮助您免于使用相同的变量用于不同的用途,并使你的代码更清晰。如果一个变量需要被重新分配,以在一个循环为例,使用let声明它。let的另一个重要方面是,使用let声明的变量只在定义它的块作用域中可用。 var是函数作用域,不是块级作用域,既然您有const和let让您随意使用,那么不应该在ES6中使用var

先require, 而不是在方法内部

在每个文件的起始位置,在任何函数的前面和外部require模块。这种简单的最佳实践,不仅能帮助您轻松快速地在文件顶部辨别出依赖关系,而且避免了一些潜在的问题。

在Node.js中,require 是同步运行的。如果从函数中调用它们,它可能会阻塞其他请求,在更关键的时间得到处理。另外,如果所require的模块或它自己的任何依赖项抛出错误并使服务器崩溃,最好尽快查明它,如果该模块在函数中require的,则不能尽早发现这种的情况。

require 文件夹,而不是文件

当在一个文件夹中开发库/模块,放置一个文件index.js暴露模块的 内部,这样每个消费者都会通过它。这将作为您模块的一个接口,并使未来的变化简单而不违反规则。

推荐:
1
2
module.exports.SMSProvider = require('./SMSProvider');
module.exports.SMSNumberResolver = require('./SMSNumberResolver');
避免:
1
2
module.exports.SMSProvider = require('./SMSProvider/SMSProvider.js');
module.exports.SMSNumberResolver = require('./SMSNumberResolver/SMSNumberResolver.js');

使用 === 操作符

对比弱等于 ==,优先使用严格的全等于 === 。== 将在它们转换为普通类型后比较两个变量。在 === 中没有类型转换,并且两个变量必须是相同的类型。

1
2
3
4
5
6
7
8
9
10
11
12
'' == '0'           // false
0 == '' // true
0 == '0' // true

false == 'false' // false
false == '0' // true

false == undefined // false
false == null // false
null == undefined // true

' \t\r\n ' == 0 // true

如果使用 === , 上面所有语句都将返回 false。

使用 Async Await, 避免回调

Node 8 LTS现已全面支持异步等待。这是一种新的方式处理异步请求,取代回调和promise。Async-await是非阻塞的,它使异步代码看起来像是同步的。您可以给你的代码的最好的礼物是用async-await提供了一个更紧凑的,熟悉的,类似try catch的代码语法。

使用 (=>) 箭头函数

尽管使用 async-await 和避免方法作为参数是被推荐的, 但当处理那些接受promise和回调的老的API的时候 - 箭头函数使代码结构更加紧凑,并保持了根方法上的语义上下文 (例如 ‘this’)。

测试和总体的质量实践

至少,编写API(组件)测试

大多数项目只是因为时间表太短而没有进行任何自动化测试,或者测试项目失控而正被遗弃。因此,优先从API测试开始,这是最简单的编写和提供比单元测试更多覆盖率的事情(你甚至可能不需要编码而进行API测试,像Postman。之后,如果您有更多的资源和时间,继续使用高级测试类型,如单元测试、DB测试、性能测试等。

使用一个linter检测代码问题

使用代码linter检查基本质量并及早检测反模式。在任何测试之前运行它, 并将其添加为预提交的git钩子, 以最小化审查和更正任何问题所需的时间。

单元测试三要素

测试报告应该告诉那些不熟悉代码的读者(测试人员,DevOps 工程师,两年后的你),是否满足他们的要求。
所以测试报告需要包含:

  1. 测试的是什么?例如,ProductsService.addNewProduct 方法
  2. 在什么场景下测试?例如,入参里没有价格。
  3. 预期的结果是什么?例如,新产品未获批准。
推荐:
1
2
3
4
5
6
7
8
9
10
//1. unit under test
describe('Products Service', () => {
describe('Add new product', () => {
//2. scenario and 3. expectation
it('When no price is specified, then the product status is pending approval', () => {
const newProduct = new ProductService().add(...);
expect(newProduct.status).to.equal('pendingApproval');
});
});
});
避免:
1
2
3
4
5
6
7
8
9
describe('Products Service', () => {
describe('Add new product', () => {
it('Should return the right status', () => {
//hmm, what is this test checking? what are the scenario and expectation?
const newProduct = new ProductService().add(...);
expect(newProduct.status).to.equal('pendingApproval');
});
});
});

用 AAA 模式构建单元测试

对于单元测试,最大的挑战就是没有人力去做。产品代码已经让我们无暇分心。出于这个原因,测试代码必须非常简单且易于理解。

当阅读测试用例时,它不应该感觉像阅读命令式代码(循环,继承),而更像HTML,一种声明性的体验。为了达到这个目的,保持 AAA 的约定,这样读者就可以毫不费力地解析测试意图。

  1. Arrange: 所有的设置代码目的是模拟场景。这包括实例化测试构造函数下的元数据,添加 DB 记录,在对象上 mocking/stubbing ,以及任何其他准备代码

  2. Act:执行测试中的单元。通常是一行代码。

  3. Assert:确保接收到的值满足期望。通常是一行代码。

推荐:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
describe.skip('Customer classifier', () => {
test('When customer spent more than 500$, should be classified as premium', () => {
//Arrange
const customerToClassify = {spent:505, joined: new Date(), id:1}
const DBStub = sinon.stub(dataAccess, 'getCustomer')
.reply({id:1, classification: 'regular'});

//Act
const receivedClassification = customerClassifier.classifyCustomer(customerToClassify);

//Assert
expect(receivedClassification).toMatch('premium');
});
});
避免:
1
2
3
4
5
6
7
test('Should be classified as premium', () => {
const customerToClassify = {spent:505, joined: new Date(), id:1}
const DBStub = sinon.stub(dataAccess, 'getCustomer')
.reply({id:1, classification: 'regular'});
const receivedClassification = customerClassifier.classifyCustomer(customerToClassify);
expect(receivedClassification).toMatch('premium');
});

避免全局设置或全局源数据

遵循黄金测试规则——保持测试用例非常简单。每个测试都应该添加并作用于它自己的DB行,以防止耦合和容易推断测试流。

推荐:
1
2
3
4
5
6
7
8
9
10
11
12
it('When updating site name, get successful confirmation', async () => {
//Arrange - test is adding a fresh new records and acting on the records only
const siteUnderTest = await SiteService.addSite({
name: 'siteForUpdateTest'
});

//Act
const updateNameResult = await SiteService.changeName(siteUnderTest, 'newName');

//Assert
expect(updateNameResult).to.be(true);
});
避免:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
before(() => {
//Arrange - adding sites and admins data to our DB. Where is the data? outside. At some external json or migration framework
await DB.AddSeedDataFromJson('seed.json');
});

it('When updating site name, get successful confirmation', async () => {
//Arrange - I know that site name 'portal' exists - I saw it in the seed files
const siteToUpdate = await SiteService.getSiteByName('Portal');

//Act
const updateNameResult = await SiteService.changeName(siteToUpdate, 'newName');

//Assert
expect(updateNameResult).to.be(true);
});

it('When querying by site name, get the right site', async () => {
//Act - I know that site name 'portal' exists - I saw it in the seed files
const siteToCheck = await SiteService.getSiteByName('Portal');

//Assert
expect(siteToCheck.name).to.be.equal('Portal'); //Failure! The previous test change the name :[
});

经常检查易受攻击的依赖项

即使是最著名的依赖项,如 Express ,也有已知的漏洞。使用社区和商业工具(如🔗npm audit和🔗snyk.io)可以很容易地解决这个问题。它们可以在每次构建时在 CI 平台上调用。

给测试做标记

不同的测试必须在不同的场景上运行,例如快速冒烟应该在开发人员保存或提交一个文件时运行;完整的端到端应该在新的请求被提交时运行。
这可以通过使用#cold #api #sanity等关键字标记测试来实现

检查您的测试覆盖率

Istanbul/NYC这样的代码覆盖工具非常棒,原因有3个:

  1. 免费。
  2. 它能帮助测试人员认识到测试覆盖率的减少。
  3. 它高亮测试覆盖的代码。

检查过期的依赖

使用你习惯的工具例如 npm outdated 或者 npm-check-updates 检擦过期的依赖。
在 CI 平台里注入这个检查,甚至在极端情况下汇报编译错误。
例如落后5个版本(本地版本是 1.3.1 而 最新仓库版本是 1.3.8),或者 该依赖已经被作者标记为已过期。

使用类生成环境做端到端测试

使用实时数据做端到端测试曾今是 CI 平台中最薄弱的环节,因为它依赖于多个重量级的依赖,例如 DB 。 使用 docker-compose 做出接近真实生产环境用来测试。

使用静态分析工具分析代码并重构

重构是迭代开发流程中的一个重要过程。移除代码异味(坏的代码实践)例如重复代码,过长函数,过长参数列表。这样可以使代码更易于维护。使用静态分析工具将帮助发现代码异味。集成这个步骤到 CI 平台。Sonar或者Code Climate 可以发现代码异味并告诉用户如何解决问题。

这些静态分析工具将补足 lint 工具例如 ESLint。大多数 lint 工具 关注单个文件的代码风格。静态分析工具关注多个文件的代码异味。

仔细挑选 CI 平台

曾经,CI世界就是易于扩展的Jenkins vs 简单方便的SaaS方案。游戏正在改变,比如SaaS提供者CircleCITravis提供了强大的解决方案,包含最小化设置时间的Docker容器,而Jenkins也尝试在简单易用性上做文章而提高竞争性。虽然您可以在云上设置丰富的CI解决方案, 如果它需要控制更多的细节Jenkins仍然是选择的平台。最终的选择归结为CI过程自定义的范围: 免安装,方便设置的云供应商允许运行自定义shell命令、自定义的docker image、调整工作流、运行matrix build和其他丰富的功能。但是, 如果使用像Java这样的正式编程语言来控制基础结构或编程CI逻辑 - Jenkins可能仍然是首选。否则, 考虑选择简单方便和设置自由的云选项。

单独测试中间件

许多人逃避测试中间件, 认为它只代表系统一小部分并且需要一个实时的Express服务。这两个原因都时错误的。中间件虽然小,但会影响所有或大多数请求。中间件可以当作入参为{req,res}的JS对象的单纯函数测试。

要测试中间件函数,只需调用它并监视(例如使用Sinon)与{req,res}对象的交互,以确保函数执行正确的操作。库node-mock-http甚至更进一步,将{req,res}对象与监视它们的行为一起分解。例如,它可以断言在res对象上设置的http状态是否与期望匹配。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//the middleware we want to test
const unitUnderTest = require("./middleware");
const httpMocks = require("node-mocks-http");
//Jest syntax, equivalent to describe() & it() in Mocha
test("A request without authentication header, should return http status 403", () => {
const request = httpMocks.createRequest({
method: "GET",
url: "/user/42",
headers: {
authentication: ""
}
});
const response = httpMocks.createResponse();
unitUnderTest(request, response);
expect(response.statusCode).toBe(403);
});

上线实践

监控

基本来说,当在生产环境中发生意外时,监控意味着你能够很容易识别它们。比如,通过电子邮件或Slack获得通知。挑战在于选择既能满足你的需求又不会破坏防护的合适工具集。我建议, 首先定义一组核心的度量标准, 这些指标必须被监视, 以确保健康状态 – CPU, 服务器RAM, Node进程RAM(小于1.4GB),最后一分钟的错误数量,进程重启次数,平均响应时间。然后去看看你可能喜欢的一些高级功能,并添加到你的愿望清单。一些高级监控功能的例子:DB分析,跨服务测量(即测量业务事务),前端集成,将原始数据展示给自定义BI客户端,Slack 通知等等。

要实现高级功能需要冗长的设置或购买诸如Datadog,Newrelic之类的商业产品。不幸的是,实现基本功能也并不容易,因为一些测量标准是与硬件相关的(CPU),而其它则在node进程内(内部错误),因此所有简单的工具都需要一些额外的设置。例如,云供应商监控解决方案(例如AWS CloudWatch, Google StackDriver)能立即告诉您硬件度量标准,但不涉及内部应用程序行为。另一方面,基于日志的解决方案(如ElasticSearch)默认缺少硬件视图。解决方案是通过缺少的指标来增加您的选择,例如,一个流行的选择是将应用程序日志发送到Elastic stack并配置一些额外的代理(例如Beat)来共享硬件相关信息以获得完整的展现。

使用智能日志使你的应用程序变得清晰

无论如何,您要打印日志,而且需要一些可以在其中跟踪错误和核心指标的接口来展示生产环境信息(例如,每小时发生了多少错误,最慢的API节点是哪一个)为什么不在健壮的日志框架中进行一些适度的尝试呢? 要实现这一目标,需要在三个步骤上做出深思熟虑的决定:

  1. 智能日志 – 在最基本的情况下,您需要使用像Winston, Bunyan这样有信誉的日志库,在每个事务开始和结束时输出有意义的信息。还可以考虑将日志语句格式化为JSON,并提供所有上下文属性(如用户id、操作类型等)。这样运维团队就可以在这些字段上操作。在每个日志行中包含一个唯一的transaction ID,更多的信息查阅条款 “Write transaction-id to log”。最后要考虑的一点还包括一个代理,它记录系统资源,如内存和CPU,比如Elastic Beat。

  2. 智能聚合 – 一旦您在服务器文件系统中有了全面的信息,就应该定期将这些信息推送到一个可以聚合、处理和可视化数据的系统中。例如,Elastic stack是一种流行的、自由的选择,它提供所有组件去聚合和产生可视化数据。许多商业产品提供了类似的功能,只是它们大大减少了安装时间,不需要主机托管。

  3. 智能可视化 – 现在的信息是聚合和可搜索的, 一个可以满足仅仅方便地搜索日志的能力, 可以走得更远, 没有编码或花费太多的努力。我们现在可以显示一些重要的操作指标, 如错误率、平均一天CPU使用, 在过去一小时内有多少新用户选择, 以及任何其他有助于管理和改进我们应用程序的指标。

委托任何可能的 (例如静态内容,gzip) 到反向代理

过度使用Express,及其丰富的中间件去提供网络相关的任务,如服务静态文件,gzip 编码,throttling requests,SSL termination等,是非常诱人的。由于Node.js的单线程模型,这将使CPU长时间处于忙碌状态 (请记住,node的执行模型针对短任务或异步IO相关任务进行了优化),因此这是一个性能消耗。一个更好的方法是使用专注于处理网络任务的工具 – 最流行的是nginx和HAproxy,它们也被最大的云供应商使用,以减轻在Node.js进程上所面临的负载问题。

锁定依赖版本

您的代码依赖于许多外部包,假设它“需要”和使用momentjs-2.1.4,默认情况下,当布署到生产中时,npm可能会获得momentjs 2.1.5,但不幸的是,这将带来一些新的bug。使用npm配置文件和设置 –save-exact=true 指示npm去完成安装,以便下次运行 npm install(在生产或在Docker容器中,您计划将其用于测试)时,将获取相同的依赖版本。另一种可选择受欢迎的方法是使用一个shrinkwrap文件(很容易使用npm生成)指出应该安装哪些包和版本,这样就不需要环境来获取新版本了。

  • 更新: 在npm5中,使用.shrinkwrap依赖项会被自动锁定。Yarn,一个新兴的包管理器,默认情况下也会锁定依赖项。

npmrc文件指示npm使用精确的版本:

1
2
// 在项目目录上保存这个为.npmrc 文件
save-exact:true

shirnkwrap.json文件获取准确的依赖关系树:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"name": "A",
"dependencies": {
"B": {
"version": "0.0.1",
"dependencies": {
"C": {
"version": "0.1.0"
}
}
}
}
}

保护和重启失败进程

在基本级别,必须保护Node进程并在出现故障时重新启动。简单地说, 对于那些小应用和不使用容器的应用 – 像这样的工具 PM2 是完美的,因为它们带来简单性,重启能力以及与Node的丰富集成。其他具有强大Linux技能的人可能会使用systemd并将Node作为服务运行。对于使用Docker或任何容器技术的应用程序来说,事情会变得更加有趣,因为集群管理和协调工具(比如AWS ECS,Kubernetes等)会完成部署,监视和保持容器健康的功能。拥有所有丰富的集群管理功能(包括容器重启),为什么还要与其他工具(如PM2)混为一谈?这里并没有可靠的答案。将PM2保留在容器(主要是其容器特定版本pm2-docker)中作为第一个守护层是有充分的理由的 - 在主机容器要求正常重启时,重新启动更快,并提供特定于node的功能比如向代码发送信号。其他选择可能会避免不必要的层。总而言之,没有一个解决方案适合所有人,但了解这些选择是最重要的。

利用CPU多核

这应该不会让人感到意外, 在其基本形式上,Node运行在单进程,单线程,单个CPU上。购买了一个强大的包含4个或8个CPU的硬件,只使用一个听起来是不可思议的,对吗?适合中型应用最快的解决方案是使用Node的Cluster模块,它在10行代码中为每个逻辑核心和路由请求产生一个进程,进程之间以round-robin的形式存在。更好的是使用PM2,它通过一个简单的接口和一个很酷的监视UI来给cluster模块裹上糖衣。虽然这个解决方案对传统应用程序很有效,但它可能无法满足需要顶级性能和健壮的devops流的应用。对于那些高级的用例,考虑使用自定义部署脚本复制NODE进程,并使用像nginx 这样的专门的工具进行负载均衡,或者使用像AWS ECS或Kubernetees这样的容器引擎,这些工具具有部署和复制进程的高级特性。

创建维护端点

维护端点是一个简单的安全的HTTP API, 它是应用程序代码的一部分, 它的用途是让ops/生产团队用来监视和公开维护功能。例如, 它可以返回进程的head dump (内存快照), 报告是否存在内存泄漏, 甚至允许直接执行 REPL 命令。在常规的 devops 工具 (监视产品、日志等) 无法收集特定类型的信息或您选择不购买/安装此类工具时, 需要使用此端点。黄金法则是使用专业的和外部的工具来监控和维护生产环境, 它们通常更加健壮和准确的。这就意味着, 一般的工具可能无法提取特定于node或应用程序的信息 – 例如, 如果您希望在 GC 完成一个周期时生成内存快照 – 很少有 npm 库会很乐意为您执行这个, 但流行的监控工具很可能会错过这个功能。

使用代码生产head dump:

1
2
3
4
5
6
7
8
9
10
11
var heapdump = require('heapdump');

router.get('/ops/headump', (req, res, next) => {
logger.info(`About to generate headump`);
heapdump.writeSnapshot(function (err, filename) {
console.log('headump file is ready to be sent to the caller', filename);
fs.readFile(filename, "utf-8", function (err, data) {
res.end(data);
});
});
});

使用APM产品确保用户体验

APM(应用程序性能监视)指的是一个产品系列, 目的是从端到端,也从客户的角度监控应用程序的性能。虽然传统的监控解决方案侧重于异常和独立的技术指标 (例如错误跟踪、检测慢速服务器节点等), 在现实世界中, 我们的应用程序可能会在没有任何代码异常的情况下让用户使用起来感到失望, 例如, 如果某些中间件服务执行得非常慢。APM 产品从端到端检测用户体验, 例如, 给定一个包含前端 UI 和多个分布式服务的系统 – 一些 APM 产品可以告诉您, 一个跨过多个层的事务的速度有多快。它可以判断用户体验是否可靠, 并指出问题所在。这种诱人的产品通常有一个相对较高的价格标签, 因此, 对于需要超越一般的监测的,大规模的和复杂的产品, 它们是值得推荐的。

为生产环境准备代码

以下是一个开发技巧的列表,它极大地影响了产品的维护和稳定性:

  • 十二因素指南 — 熟悉12因素指南
  • 无状态 — 在一个特定的web服务器上不保存本地数据(请参阅相关条目 - “Be Stateless”)
  • 高速缓存 — 大量使用缓存,但不会因为缓存不匹配而产生错误
  • 测试内存 — 测量内存的使用和泄漏,是作为开发流程的一部分,诸如“memwatch”之类的工具可以极大地促进这一任务
  • 命名函数 — 将匿名函数(例如,内联callbabk)的使用最小化,因为一个典型的内存分析器为每个方法名提供内存使用情况
  • 使用CI工具 — 在发送到生产前使用CI工具检测故障。例如,使用ESLint来检测引用错误和未定义的变量。使用–trace-sync-io来识别用了同步api的代码(而不是异步版本)
    明确的日志 — 包括在每个日志语句中希望用json格式记录上下文信息,以便于日志聚合工具,如Elastic可以在这些属性上搜索(请参阅相关条目 – “Increase visibility using smart logs”)。此外,还包括标识每个请求的事务id,并允许将描述相同事务的行关联起来(请参阅 — “Include Transaction-ID”)
  • 错误管理 — 错误处理是Node.js生产站点的致命弱点 – 许多Node进程由于小错误而崩溃,然而其他Node进程则会在错误的状态下存活,而不是崩溃。设置你的错误处理策略绝对是至关重要的, 在这里阅读我的(错误处理的最佳实践)(http://goldbergyoni.com/checklist-best-practices-of-node-js-error-handling/)

测量和防范内存使用情况

在一个完美的开发过程中, Web开发人员不应该处理内存泄漏问题。 实际上,内存问题是一个必须了解的Node已知的问题。首先,内存使用必须不断监视.在开发和小型生产站点上,您可以使用Linux命令或NPM工具和库(如node-inspector和memwatch)来手动测量。 这个人工操作的主要缺点是它们需要一个人进行积极的监控 - 对于正规的生产站点来说,使用鲁棒性监控工具是非常重要的,例如(AWS CloudWatch,DataDog或任何类似的主动系统),当泄漏发生时提醒。 防止泄漏的开发指南也很少:避免将数据存储在全局级别,使用动态大小的流数据,使用let和const限制变量范围。

在node外处理您的前端资产

在一个经典的 web 应用中,后端返回前端资源/图片给浏览器, 在node的世界,一个非常常见的方法是使用 Express 静态中间件, 以数据流的形式把静态文件返回到客户端。但是, node并不是一个典型的 web应用, 因为它使用单个线程,对于同时服务多个文件,未经过任何优化。相反, 考虑使用反向代理、云存储或 CDN (例如Nginx, AWS S3, Azure Blob 存储等), 对于这项任务, 它们做了很多优化,并获得更好的吞吐量。例如, 像 nginx 这样的专业中间件在文件系统和网卡之间的直接挂钩, 并使用多线程方法来减少多个请求之间的干预。

您的最佳解决方案可能是以下形式之一:

  1. 反向代理 – 您的静态文件将位于您的node应用的旁边, 只有对静态文件文件夹的请求才会由位于您的node应用前面的代理 (如 nginx) 提供服务。使用这种方法, 您的node应用负责部署静态文件, 而不是为它们提供服务。你的前端的同事会喜欢这种方法, 因为它可以防止 cross-origin-requests 的前端请求。
  2. 云存储 – 您的静态文件将不会是您的node应用内容的一部分, 他们将被上传到服务, 如 AWS S3, Azure BlobStorage, 或其他类似的服务, 这些服务为这个任务而生。使用这种方法, 您的node应用即不负责部署静态文件, 也不为它们服务, 因此, 在node和前端资源之间完全解耦, 这是由不同的团队处理。

保持服务器无状态

确保服务器无状态,随时可以更换,并无任何负面影响。

避免:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//典型错误1: 保存上传文件在本地服务器上
var multer = require('multer') // 处理multipart上传的express中间件
var upload = multer({ dest: 'uploads/' })

app.post('/photos/upload', upload.array('photos', 12), function (req, res, next) {})

//典型错误2: 在本地文件或者内存中,保存授权会话(密码)
var FileStore = require('session-file-store')(session);
app.use(session({
store: new FileStore(options),
secret: 'keyboard cat'
}));

//典型错误3: 在全局对象中保存信息
Global.someCacheLike.result = {somedata}

使用工具自动检测有漏洞的依赖项

现代node应用有数十个, 有时是数以百计的依赖。如果您使用的任何依赖项存在已知的安全漏洞, 您的应用也很容易受到攻击。 下列工具自动检查依赖项中的已知安全漏洞: npm audit - Node 安全工程 snyk - 持续查找和修复依赖中的漏洞。

在每一个log语句中指明 ‘TransactionId’

一个典型的日志是来自所有组件和请求的条目的仓库。当检测到一些可疑行或错误时,为了与其他属于同一特定流程的行(如用户“约翰”试图购买某物)相匹配,就会变得难以应付。特别在微服务环境下,当一个请求/交易可能跨越多个计算机,这变得更加重要和具有挑战性。解决这个问题,可以通过指定一个唯一的事务标识符给从相同的请求过来的所有条目,这样当检测到一行,可以复制这个id,并搜索包含这个transaction id的每一行。但是,在node中实现这个不是那么直截了当的,这是由于它的单线程被用来服务所有的请求 – 考虑使用一个库,它可以在请求层对数据进行分组 – 在下一张幻灯片查看示例代码。当调用其它微服务,使用HTTP头“x-transaction-id”传递transaction id去保持相同的上下文。

配置环境变量 NODE_ENV = production

进程的环境变量是一组键值对,可用于任何运行程序,通常用于配置。虽然可以使用其他任何变量,但Node鼓励使用一个名为NODE_ENV的变量来标记我们是否正在开发。这一决定允许组件在开发过程中能提供更好的诊断,例如禁用缓存或发出冗长的日志语句。任何现代部署工具 — Chef、Puppet、CloudFormation等 — 在部署时都支持设置环境变量。

设计自动化、原子化和零停机时间部署

研究表明,执行许多部署的团队降低了严重上线问题的可能性。不需要危险的手动步骤和服务停机时间的快速和自动化部署大大改善了部署过程。你应该达到使用Docker结合CI工具,使他们成为简化部署的行业标准。

使用 Node.js 的 LTS 版本

确保您在正式环境中使用的是LTS(长期支持)版本的Node.js来获取关键错误的修复、安全更新和性能改进。

LTS版本的Node.js至少支持18个月,并由偶数版本号(例如 4、6、8)表示。它们最适合生产环境,因为LTS的发行线专注于稳定性和安全性,而“Current”版本发布寿命较短,代码更新更加频繁。LTS版本的更改仅限于稳定性错误修复、安全更新、合理的npm更新、文档更新和某些可以证明不会破坏现有应用程序的性能改进。

应用程序不要处理日志保存

应用程序应更关注于逻辑代码。关于日志保存,例如保存到哪个文件,哪个数据库,这些不是应用程序应该管的。

应用程序只需要把日志输出到 stdout/stderr 中, Docker 的 log-driver 复制具体的日志保存。

推荐:

在应用程序中:

1
2
3
4
5
6
7
8
const logger = new winston.Logger({
level: 'info',
transports: [
new (winston.transports.Console)()
]
});

logger.log('info', 'Test Log Message with some parameter %s', 'some parameter', { anything: 'This is metadata' });

在Docker容器的daemon.json

1
2
3
4
5
6
7
8
{
"log-driver": "splunk", // just using Splunk as an example, it could be another storage type
"log-opts": {
"splunk-token": "",
"splunk-url": "",
//...
}
}

生产模式下使用 npm ci 安装依赖

  • 如果没有 package-lock.json 或该文件与 package.json 不相符,则报错失败。
  • 自动删除 node_modules 文件夹。
  • 更快。参考 the release blog post

安全最佳实践

拥护linter安全准则

ESLint 和 TSLint 的安全插件例如eslint-plugin-securitytslint-config-security 提供一系列风险代码安全检查。

使用负载均衡或中间件处理并发请求

应该在应用程序中实现速率限制,以保护Node.js应用程序不会同时被过多的请求所淹没。速率限制任务最好使用专为该任务设计的服务执行,例如nginx,但是也可以使用 rate-limiter-flexible 包或中间件,例如Express.js应用程序的 express-rate-limiter

rate-limiter-flexible 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const http = require('http');
const redis = require('redis');
const { RateLimiterRedis } = require('rate-limiter-flexible');

const redisClient = redis.createClient({
enable_offline_queue: false,
});

// Maximum 20 requests per second
const rateLimiter = new RateLimiterRedis({
storeClient: redisClient,
points: 20,
duration: 1,
blockDuration: 2, // block for 2 seconds if consumed more than 20 points per second
});

http.createServer(async (req, res) => {
try {
const rateLimiterRes = await rateLimiter.consume(req.socket.remoteAddress);
// Some app logic here

res.writeHead(200);
res.end();
} catch {
res.writeHead(429);
res.end('Too Many Requests');
}
})
.listen(3000);

express-rate-limiter 示例:

1
2
3
4
5
6
7
8
9
10
11
const RateLimit = require('express-rate-limit');
// important if behind a proxy to ensure client IP is passed to req.ip
app.enable('trust proxy');

const apiLimiter = new RateLimit({
windowMs: 15*60*1000, // 15 minutes
max: 100,
});

// only apply to requests that begin with /user/
app.use('/user/', apiLimiter);

把机密信息从配置文件中抽离出来,或者使用包对其加密

最常见最安全的提供Nodejs访问机密的方法是把它们保存在系统的环境变量中。一旦设置,程序可以用process.env访问它们。
对于需要在源代码控制中保存机密的情况,可以使用加密包例如cryptr加密文本。

系统环境变量示例:

1
2
3
4
const azure = require('azure');

const apiKey = process.env.AZURE_STORAGE_KEY;
const blobService = azure.createBlobService(apiKey);

cryptr示例:

1
2
3
4
5
6
const Cryptr = require('cryptr');
const cryptr = new Cryptr(process.env.SECRET);

let accessToken = cryptr.decrypt('e74d7c0de21e72aaffc8f2eef2bdb7c1');

console.log(accessToken); // outputs decrypted string which was not stored in source control

使用 ORM/ODM 库防止查询注入漏洞

通用安全最佳实践集合

这些是与Node.js不直接相关的安全建议的集合-Node的实现与任何其他语言没有太大的不同。

使用 HTTPS 加密连接
1
2
3
4
5
6
7
8
9
const express = require('express');
const https = require('https');
const app = express();
const options = {
// The path should be changed accordingly to your setup
cert: fs.readFileSync('./sslcert/fullchain.pem'),
key: fs.readFileSync('./sslcert/privkey.pem')
};
https.createServer(options, app).listen(443);
安全地比较秘密值和哈希值

当比较秘密值或像 HMAC digests 这样的哈希值时,应该使用 crypto.timingSafeEqual(a, b) 函数,Node.js v6.6.0开始提供。

使用Node.js生成随机字符串

当你必须生成安全的随机字符串时,使用 crypto.randomBytes(size, [callback]) 函数使用系统提供的可用熵。

OWASP 建议

参考

调整 HTTP 响应头以加强安全性

参考

经常自动检查易受攻击的依赖库

参考

保护用户的密码

始终散列用户密码,而不是将它们存储为文本。

  • 对于大多数用例,可以使用流行的库bcrypt。(最小:cost:12,密码长度必须小于64)
  • 对于稍微难一点的本地解决方案,或无限大小的密码,请使用scrypt函数。(最小值:N:32768, r:8, p:1)
  • 对于 FIPS/Government compliance ,使用本地加密模块中包含的较旧的PBKDF2函数。(最小值:迭代:10000,长度:{salt: 16,密码:32})

转义输出

发送给浏览器的不受信任数据可能会被执行, 而不是显示, 这通常被称为跨站点脚本(XSS)攻击。使用专用库将数据显式标记为不应执行的纯文本内容,可以减轻这种问题。

验证传入的JSON schemas

验证传入请求的body payload,并确保其符合预期要求, 如果没有, 则快速报错。

支持黑名单的JWT

参考

限制登录请求,防止暴力破解

将更高特权的路由(如/login或/admin)暴露在没有速率限制的情况下,会使应用程序面临暴力破解密码字典攻击的风险。使用一种策略将请求限制在这样的路由上,可以通过限制基于请求属性(如ip)或主体参数(如用户名/电子邮件地址)的允许尝试的数量来防止成功。

rate-limiter-flexible 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const maxWrongAttemptsByIPperDay = 100;
const maxConsecutiveFailsByUsernameAndIP = 10;

const limiterSlowBruteByIP = new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'login_fail_ip_per_day',
points: maxWrongAttemptsByIPperDay,
duration: 60*60*24,
blockDuration: 60*60*24, // Block for 1 day, if 100 wrong attempts per day
});

const limiterConsecutiveFailsByUsernameAndIP = new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'login_fail_consecutive_username_and_ip',
points: maxConsecutiveFailsByUsernameAndIP,
duration: 60*60*24*90, // Store number for 90 days since first fail
blockDuration: 60*60, // Block for 1 hour
});

使用非root用户运行Node.js

根据“最小特权原则”,用户/进程必须只能访问必要的信息和资源。向攻击者授予根访问权限打开了一个全新的恶意想法的世界,比如将流量路由到其他服务器。在实践中,大多数Node.js应用程序不需要root访问权限,也不使用这种特权运行。然而,有两种常见的场景可能会推动根用户使用:

  • 要访问特权端口(例如80端口),Node.js必须以root身份运行
  • Docker容器默认以root(!)运行。建议Node.js web应用程序侦听非特权端口,并依赖像nginx这样的反向代理将传入的流量从端口80重定向到Node.js应用程序。在构建Docker映像时,高度安全的应用程序应该使用替代的非根用户运行容器。大多数Docker集群(如Swarm, Kubernetes)允许以声明的方式设置安全上下文

使用反向代理或中间件限制负载大小

解析request主体(例如json编码的有效负载)是一项性能要求很高的操作,对于较大的请求尤其如此。在web应用程序中处理传入request时,应该限制它们各自有效负载的大小。

express 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const express = require('express');

const app = express();

app.use(express.json({ limit: '300kb' })); // body-parser defaults to a body size limit of 100kb

// Request with json body
app.post('/json', (req, res) => {

// Check if request payload content-type matches json, because body-parser does not check for content types
if (!req.is('json')) {
return res.sendStatus(415); // -> Unsupported media type if request doesn't have JSON body
}

res.send('Hooray, it worked!');
});

app.listen(3000, () => console.log('Example app listening on port 3000!'));

nginx 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
http {
...
# Limit the body size for ALL incoming requests to 1 MB
client_max_body_size 1m;
}

server {
...
# Limit the body size for incoming requests to this specific server block to 1 MB
client_max_body_size 1m;
}

location /upload {
...
# Limit the body size for incoming requests to this route to 1 MB
client_max_body_size 1m;
}

避免JS eval语法

参考

防止恶意RegEx让Node.js的单线程过载执行

正则表达式,在方便的同时,对JavaScript应用构成了真正的威胁,特别是Node.js平台。匹配文本的用户输入需要大量的CPU周期来处理。在某种程度上,正则处理是效率低下的,比如验证10个单词的单个请求可能阻止整个event loop长达6秒,并让CPU引火烧身。由于这个原因,偏向第三方的验证包,比如validator.js,而不是采用正则。

避免使用变量加载模块

避免使用被指定为参数的路径变量导入(requiring/importing)另一个文件, 因为该变量可能源自用户输入。此规则可以扩展到一般情况下的访问文件(例如,fs.readFile()),或者包含源自用户输入的动态变量的其他敏感资源。

推荐:
1
2
// 安全
const uploadHelpers = require('./helpers/upload');
避免:
1
2
// 不安全, 因为helperPath变量可能通过用户输入而改变
const uploadHelpers = require(helperPath);

在沙箱中运行不安全代码

三个主要选项可以帮助实现这种隔离:

  • 一个专门的子进程 - 这提供了一个快速的信息隔离, 但要求制约子进程, 限制其执行时间, 并从错误中恢复。
  • 一个基于云的无服务框架满足所有沙盒要求,但动态部署和调用Faas方法不是本部分的内容。
  • 一些npm库,比如vm2sandbox允许通过一行代码执行隔离代码。

处理子进程时要谨慎

尽管子进程非常棒, 但使用它们应该谨慎。如果无法避免传递用户输入,就必须经过脱敏处理。 未经脱敏处理的输入执行系统级逻辑的危险是无限的, 从远程代码执行到暴露敏感的系统数据, 甚至数据丢失。准备工作的检查清单可能是这样的:

  • 避免在每一种情况下的用户输入, 否则验证和脱敏处理。
  • 使用user/group标识限制父进程和子进程的权限。
  • 在隔离环境中运行进程, 以防止在其他准备工作失败时产生不必要的副作用。

未脱敏处理子进程的危害:

1
2
3
4
5
6
7
8
9
const { exec } = require('child_process');

...

// 例如, 以一个脚本为例, 它采用两个参数, 其中一个参数是未经脱敏处理的用户输入
exec('"/path/to/test file/someScript.sh" --someOption ' + input);

// -> 想象一下, 如果用户只是输入'&& rm -rf --no-preserve-root /'类似的东西, 会发生什么
// 你会得到一个不想要的结果

隐藏客户端的错误详细信息

默认情况下, 集成的express错误处理程序隐藏错误详细信息。但是, 极有可能, 您实现自己的错误处理逻辑与自定义错误对象(被许多人认为是最佳做法)。如果这样做, 请确保不将整个Error对象返回到客户端, 这可能包含一些敏感的应用程序详细信息。

1
2
3
4
5
6
7
8
9
// production error handler
// no stacktraces leaked to user
app.use((err, req, res, next) => {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: {}
});
});

对npm或Yarn,配置2FA

开发链中的任何步骤都应使用MFA(多重身份验证)进行保护, npm/Yarn对于那些能够掌握某些开发人员密码的攻击者来说是一个很好的机会。使用开发人员凭据, 攻击者可以向跨项目和服务广泛安装的库中注入恶意代码。甚至可能在网络上公开发布。在npm中启用2因素身份验证(2-factor-authentication), 攻击者几乎没有机会改变您的软件包代码。

修改session中间件设置

每个web框架和技术都有其已知的弱点-告诉攻击者我们使用的web框架对他们来说是很大的帮助。使用session中间件的默认设置, 可以以类似于X-Powered-Byheader的方式向模块和框架特定的劫持攻击公开您的应用。尝试隐藏识别和揭露技术栈的任何内容(例如:Nonde.js, express)。

1
2
3
4
5
6
7
8
9
10
// using the express session middleware
app.use(session({
secret: 'youruniquesecret', // secret string used in the signing of the session ID that is stored in the cookie
name: 'youruniquename', // set a unique name to remove the default connect.sid
cookie: {
httpOnly: true, // minimize risk of XSS attacks by restricting the client from reading the cookie
secure: true, // only send cookie over https
maxAge: 60000*60*24 // set cookie expiry length in ms
}
}));

通过显式设置进程应崩溃的情况,以避免DOS攻击

当错误未被处理时, Node进程将崩溃。即使错误被捕获并得到处理,许多最佳实践甚至建议退出。例如, Express会在任何异步错误上崩溃 - 除非使用catch子句包装路由。这将打开一个非常惬意的攻击点, 攻击者识别哪些输入会导致进程崩溃并重复发送相同的请求。没有即时补救办法, 但一些技术可以减轻苦楚: 每当进程因未处理的错误而崩溃,都会发出警报,验证输入并避免由于用户输入无效而导致进程崩溃,并使用catch将所有路由处理包装起来,并在请求中出现错误时, 考虑不要崩溃(与全局发生的情况相反)。

避免不安全的重定向

当我们在 Node.js 或者 Express 中实现重定向时,在服务器端进行输入校验非常重要。当攻击者发现你没有校验用户提供的外部输入时,他们会在论坛、社交媒体以和其他公共场合发布他们精心制作的链接来诱使用户点击,以此达到漏洞利用的目的。

express 使用用户输入的不安全的重定向:

1
2
3
4
5
6
7
8
9
10
const express = require('express');
const app = express();

app.get('/login', (req, res, next) => {

if (req.session.isAuthenticated()) {
res.redirect(req.query.url);
}

});

建议的避免不安全重定向的方案是,避免依赖用户输入的内容来进行重定向。如果一定要使用用户输入的内容,可以通过使用白名单重定向的方式来避免暴露漏洞。

使用白名单实现安全的重定向:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const whitelist = {
'https://google.com': 1
};

function getValidRedirect(url) {
// 检查url是否以/开头
if (url.match(/^\/(?!\/)/)) {
// 前置我们的域名来确保(安全)
return 'https://example.com' + url;
}

// 否则对照白名单列表
return whitelist[url] ? url : '/';
}

app.get('/login', (req, res, next) => {

if (req.session.isAuthenticated()) {
res.redirect(getValidRedirect(req.query.url));
}

});

避免将机密信息发布到NPM仓库

您应该采取预防措施来避免偶然地将机密信息发布到npm仓库的风险。 一个 .npmignore 文件可以被用作忽略掉特定的文件或目录, 或者一个在 package.json 中的 files 数组可以起到一个白名单的作用.

Docker 最佳实践

参考