当项目周期快结束时,开发人员会越来越关注应用的“安全性”问题。一个安全的应用程序并不是一种奢侈,而是必要的。你应该在开发的每个阶段都考虑应用程序的安全性,例如系统架构、设计、编码,包括最后的部署。

在这篇教程中,我们将一步步来学习如何提高Node.js应用程序安全性的方法。

1. 数据验证 - 永远不要信任你的用户

来自用户输入或其他系统的数据,你都必须要进行验证。否则,这会对当前系统造成威胁,并导致不可想象的安全漏洞。现在,让我们学习如何验证Node.js中的传入数据。你可以使用名为 validator 的模块来执行数据验证。 例如:

const validator = require('validator'); 
validator.isEmail('foo@bar.com'); //=> true 
validator.isEmail('bar.com'); //=> false

另外,你也可以使用 joi 模块来进行数据和模型的验证,例如:

const joi = require('joi'); 
  try { 
    const schema = joi.object().keys({ 
      name: joi.string().min(3).max(45).required(), 
      email: joi.string().email().required(), 
      password: joi.string().min(6).max(20).required() 
    }); 
 
    const dataToValidate = { 
        name: "Shahid", 
        email: "abc.com", 
        password: "123456", 
    } 
    const result = schema.validate(dataToValidate); 
    if (result.error) { 
      throw result.error.details[0].message; 
    }     
  } catch (e) { 
      console.log(e); 
  }

2. SQL注入攻击

SQL注入可以让恶意用户通过传递非法参数,来篡改SQL语句。下面是一个例子,假设你写了这样一个SQL:

UPDATE users  
SET first_name="' + req.body.first_name + '" WHERE id=1332;

在正常情况下,你希望这次查询应该是这样的:

UPDATE users  SET first_name = "John" WHERE id = 1332;

但是现在,如果有人将 first_name 的值按下面这种方式传递:

John", last_name="Wick"; --

这时,你的SQL语句就会变成这样:

UPDATE users  SET first_name="John", last_name="Wick"; --" WHERE id=1001;

你会看到, WHERE 条件被注释掉了,这次更新会将整张表所有用户的 first_name 改成 John , last_name 改成 Wick 。这下,你闯祸了!

如何避免SQL注入

避免SQL注入攻击最有效的办法就是将输入数据进行过滤。你可以对每一个输入数据逐一进行验证,也可以用参数绑定的方式验证。开发者们最常用的就是参数绑定的方式,因为它高效而且安全。

如果你在使用一些比较流行的ORM框架,例如sequelize、hibernate等等,那么框架中就已经提供了这种数据验证和SQL注入保护机制。

如果你更喜欢依赖数据库模块,例如 mysql for Node ,那么你可以使用数据库提供的过滤方法。下面代码是使用 mysql for Node 的一个例子:

var mysql = require('mysql'); 
var connection = mysql.createConnection({ 
  host     : 'localhost', 
  user     : 'me', 
  password : 'secret', 
  database : 'my_db' 
}); 
 
connection.connect(); 
 
connection.query( 
    'UPDATE users SET ?? = ? WHERE ?? = ?', 
    ['first_name',req.body.first_name, ,'id',1001], 
    function(err, result) { 
    //... 
});

?? 的地方被字段名称替换, ? 的地方被字段值替换,这样就保证了输入值的安全性。

你也可以使用存储过程来提高安全级别,但是由于缺乏可维护性,开发人员倾向于避免使用存储过程。

同时,你还应该执行服务器端的数据验证。 但我不建议你手动验证每个字段,可以使用 joi 等模块来解决这个问题。

类型转换

JavaScript是一种动态类型语言,即值可以是任何类型。你可以使用类型转换方法来验证数据的类型,这样就能保证,只有指定类型的数据才可以进入数据库。比如,用户ID只能是数字类型,看下面的代码:

var mysql = require('mysql'); 
var connection = mysql.createConnection({ 
  host     : 'localhost', 
  user     : 'me', 
  password : 'secret', 
  database : 'my_db' 
}); 
 
connection.connect(); 
 
connection.query( 
    'UPDATE users SET ?? = ? WHERE ?? = ?', 
    ['first_name',req.body.first_name, ,'id',Number(req.body.ID)], 
    function(err, result) { 
    //... 
});

你注意到变化了吗?这里我们使用了 Number(req.body.ID) 方法来确保用户ID必须为数字。

3. 应用程序认证和授权

敏感数据(例如密码)应该以一种安全的方式存储在系统中,这样,恶意用户就不会滥用敏感信息。在本节中,我们将学习如何存储和管理通用的密码,几乎每个应用程序在其系统中都有不同的密码存储方式。

密码哈希

哈希是一个将输入值生成固定大小字符串的函数。哈希函数的输出值是无法解密的,因此可以说是“单向的”。因此,像密码这样的数据,存储在数据库中的值必须是哈希值,而不是明文。

你也许想知道,既然哈希是一种不可逆的加密方式,那么攻击者又是如何取得密码访问权限的呢?

正如我上面提到的那样,哈希加密使用输入字符串并生成固定长度的输出值。因此,攻击者采取了相反的方法,他们从常规密码列表中生成哈希,然后将哈希与系统中的哈希进行比较以找到密码。这种攻击方式叫做查表法(Lookup Tables)

这就是为什么你作为系统架构师,绝不允许系统中使用简单通用密码的原因。为了避免攻击,你也可以使用一种叫”salt"的东西,我们称之为“哈希加盐法”。将salt附加到密码哈希中,从而使输入值唯一。salt值必须是随机的不可预测的。我们建议你使用的哈希算法是 BCrypt ,在Node.js中,你可以使用bcyrpt节点模块执行哈希处理。

请参考下面例子中的代码:

const bcrypt = require('bcrypt'); 
 
const saltRounds = 10; 
const password = "Some-Password@2020"; 
 
bcrypt.hash( 
    password, 
    saltRounds, 
    (err, passwordHash) => { 
 
    //we will just print it to the console for now 
    //you should store it somewhere and never logs or print it 
 
    console.log("Hashed Password:", passwordHash); 
});

SaltRounds 函数是哈希函数的成本,成本越高,生成的hash密码越安全。你应该根据服务器的计算能力来确定salt值,密码的hash值生成后,用户输入的密码将会和存储在数据库中的hash值比对,参考代码如下:

const bcrypt = require('bcrypt'); 
 
const incomingPassword = "Some-Password@2020"; 
const existingHash = "some-hash-previously-generated" 
 
bcrypt.compare( 
    incomingPassword, 
    existingHash, 
    (err, res) => { 
        if(res && res === true) { 
            return console.log("Valid Password"); 
        } 
        //invalid password handling here 
        else { 
            console.log("Invalid Password"); 
        } 
});

密码存储

无论你是用数据库还是文件来存储密码,都不能使用明文存储。我们在上一节已经学到,你可以将密码进行哈希后存储在数据库中。我推荐密码字段用 varchar(255) 数据类型,你也可以选择不限长度的字段类型。如果你使用的是 bcrypt ,则可以使用 varchar(60) 字段类型,因为bcrypt会生成固定长度为60个字符的哈希。

认证和授权

一个拥有合适的角色权限系统,将会阻止一些恶意用户在系统中做一些越权的事情。为了实现正确的授权过程,将合适的角色和权限分配给每个用户,以便他们可以执行权限范围内的某些任务。在Node.js中,你可以使用著名的ACL模块,根据系统中的授权来开发访问控制列表。

const ACL = require('acl2'); const acl = new ACL(new ACL.memoryBackend()); // guest is allowed to view blogs acl.allow('guest', 'blogs', 'view') // check if the permission is granted acl.isAllowed('joed', 'blogs', 'view', (err, res) => {     if(res){         console.log("User joed is allowed to view blogs");     } });

请查阅acl2文档以获取更多信息和示例代码。

4. 暴力攻击防护

黑客经常会使用软件反复使用不同的密码尝试获得系统权限,直到找到有效密码为止,这种攻击方式叫做暴力攻击。为了避免这种攻击,一种简单有效的办法是“让他等一会”,也就是说,当某人尝试登录系统并尝试输入无效密码3次以上时,请让他们等待60秒左右,然后再尝试。这样,攻击者将大大提高时间成本,并且将使他们永远无法破解密码。

防止这种攻击的另一种方法是屏蔽无效登录请求的IP。系统在24小时内允许每个IP进行3次错误地登录尝试。如果有人尝试进行暴力破解,则将其IP封锁24小时。 许多公司已使用这种方法来防止暴力攻击。 如果使用Express框架,则有一个中间件模块可在传入请求中启用速率限制。 它称为 express = brute 。

下面是一个例子。

安装依赖项

npm install express-brute --save

在路由中启用它

const ExpressBrute = require('express-brute'); 
const store = new ExpressBrute.MemoryStore(); // stores state locally, don't use this in production 
const bruteforce = new ExpressBrute(store); 
 
app.post('/auth', 
    bruteforce.prevent, // error 429 if we hit this route too often 
    function (req, res, next) { 
        res.send('Success!'); 
    } 
); 
//...

5. HTTPS安全传输

现在已经2021年了,你也应该使用HTTPS来向网络中发送数据了。HTTPS是具有安全通信支持的HTTP协议的扩展。使用HTTPS,可以保证用户在互联网中发送的数据是被加密的,是安全的。

在这里我不打算详细介绍HTTPS协议的工作原理,我们只讨论如何使用它。这里我强烈推荐使用 LetsEncrypt 来为你的所有域名生成安全证书。

你可以在基于Apache和Nginx的Web服务器上使用LetsEncrypt。我强烈建议你在反向代理或网关层上使用HTTPS协议,因为它们有很多繁重的计算操作。

6. 会话劫持保护

会话(session)是任何动态Web应用程序最重要的部分,一个安全的会话对用户和系统来说真的是非常必要的。会话是使用Cookie实现的,因此必须确保其安全以防止会话劫持。以下是可以为每个cookie设置的属性列表以及它们的含义:

  • secure - 该属性告诉浏览器仅在通过HTTPS发送请求时才发送cookie。

  • HttpOnly - 该属性用于防止跨站点脚本攻击,因为它不允许通过JavaScript访问cookie。

  • domain - 该属性用于与请求URL的服务器域名进行比较,如果域名匹配,或者是其子域,那么接下来就会检查path属性。

  • path - 除了domian之外,还可以指定cookie有效的URL路径。 如果domain和路径匹配,则可以在请求中发送cookie。

  • expires - 该属性用于设置持久性的cookie,cookie只会在超过了设定的日期才会过期。

在Express框架中,你可以使用 express-session npm模块来管理会话。

const express = require('express'); 
const session = require('express-session'); 
const app = express(); 
 
app.use(session({ 
  secret: 'keyboard cat', 
  resave: false, 
  saveUninitialized: true, 
  cookie: { secure: true, path: '/'} 
}));

7. 跨站点请求伪造攻击(CSRF)防护

跨站点请求伪造攻击利用系统中受信任的用户,对Web应用程序执行有害的恶意操作。在Node.js中,我们可以使用 csurf 模块来缓解CSRF攻击。该模块需要首先初始化 express-session 或 cookie-parser ,你可以看看下面的示例代码:

const express = require('express'); 
const cookieParser = require('cookie-parser'); 
const csrf = require('csurf'); 
const bodyParser = require('body-parser'); 
 
// setup route middlewares 
const csrfProtection = csrf({ cookie: true }); 
const parseForm = bodyParser.urlencoded({ extended: false }); 
 
// create express app 
const app = express(); 
 
// we need this because "cookie" is true in csrfProtection 
app.use(cookieParser()); 
 
app.get('/form', csrfProtection, function(req, res) { 
  // pass the csrfToken to the view 
  res.render('send', { csrfToken: req.csrfToken() }); 
}); 
 
app.post('/process', parseForm, csrfProtection, function(req, res) { 
  res.send('data is being processed'); 
}); 
 
app.listen(3000);

在网页上,你需要创建一个隐藏输入域,将CSRF令牌保存在该输入域中,例如:

<form action="/process" method="POST"> 
  <input type="hidden" name="_csrf" value="{{csrfToken}}"> 
 
  Favorite color: <input type="text" name="favoriteColor"> 
  <button type="submit">Submit</button> 
</form>

如果使用的是AJAX请求,那么CSRF令牌可以通过请求头(header)来传递。

var token = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); 
  headers: { 
    'CSRF-Token': token 
  }

8. 拒绝服务

拒绝服务或DOS攻击,可以让攻击者通过破坏系统,使系统被迫关闭服务或用户无法访问服务。攻击者通常会向系统发送大量的流量和请求,从而增加服务器CPU和内存负载,导致系统崩溃。为了缓解Node.js应用程序中的DOS攻击,首先是要识别此类事件, 我强烈建议将这两个模块集成到系统中。

  1. Account lockout - 在n次尝试失败后,将帐户或IP地址锁定一段时间(例如24小时?)

  2. Rate limiting - 限制用户在特定时间段内只能请求系统n次,例如,单个用户每分钟只能请求3次。

正则表达式拒绝服务攻击(ReDOS)是DOS攻击的一种,攻击者利用系统中正则表达式的设计缺陷或计算复杂度来大量消耗服务器的系统资源,造成服务器的服务中断或停止。

我们可以使用一些工具来检查有风险的正则表达式,从而避免这些正则表达式的使用。例如这个工具:

https://github.com/davisjam/vuln-regex-detector

9. 依赖关系验证

我们在项目中都使用了大量的依赖项。我们还需要检查并验证这些依赖关系,以确保整个项目的安全性。NPM已经具有这样的审核功能来查找项目的漏洞。只需在源代码目录中运行下面的命令即可:

npm audit

要修复漏洞,可以运行此命令:

npm audit fix

您也可以先进行 dry run 来检查修复程序,然后再将其应用到项目中。

npm audit fix --dry-run --json

10. HTTP安全头信息

HTTP提供了一些安全头信息,可以防止常见的攻击。如果使用的是Express框架,则可以使用 helmet 模块,1行代码就可以启用所有安全头。

npm install helmet --save

下面来看看如何使用:

const express = require("express");  const helmet = require("helmet");   const app = express();  app.use(helmet());   //...

这将启用以下HTTP头:

  • Strict-Transport-Security

  • X-frame-Options

  • X-XSS-Protection

  • X-Content-Type-Protection

  • Content-Security-Policy

  • Cache-Control

  • Expect-CT

  • Disable X-Powered-By

这些HTTP头可防止恶意用户的各种攻击,例如点击劫持,跨站点脚本攻击等。