Node.js 安全指南

前端新世界發表於2021-01-31

當專案週期快結束時,開發人員會越來越關注應用的“安全性”問題。一個安全的應用程式並不是一種奢侈,而是必要的。你應該在開發的每個階段都考慮應用程式的安全性,例如系統架構、設計、編碼,包括最後的部署。

在這篇教程中,我們將一步步來學習如何提高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改成Johnlast_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-sessioncookie-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攻擊,首先是要識別此類事件, 我強烈建議將這兩個模組整合到系統中。

  • Account lockout - 在n次嘗試失敗後,將帳戶或IP地址鎖定一段時間(例如24小時?)
  • 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頭可防止惡意使用者的各種攻擊,例如點選劫持,跨站點指令碼攻擊等。

(本文完)

公眾號 - 前端新世界

相關文章