從零搭建 Node.js 企業級 Web 伺服器(八):網路安全

烏柏木發表於2020-07-31

關於網路安全

計算機網路依據 TCP/IP 協議棧分為了物理層、網路層、傳輸層、應用層,通常基礎設施供應商會解決好前三層的網路安全問題,需要開發者自行解決應用層的網路安全問題,本章將著重表述應用層常見的網路安全問題及處理方法。

9f68f11809933ff550cff53d48bdf1a726f9fb4f.jpg

常見的應用層攻擊手段

XSS

XSS(cross-site scripting),跨站指令碼攻擊,通過在頁面中注入指令碼發起攻擊。舉個例子:我在一個有 XSS 缺陷的線上商城開了一家店鋪,編輯商品詳情頁時提交了這樣的描述:特製辣醬<script src="https://cross-site.scripting/attack.js"></script>,當使用者訪問該商品的詳情時 attack.js 就被執行了,我通過該指令碼可以在使用者不知情的情況下竊取資料或者發起操作,比如:把使用者正在瀏覽的商品加入到購物車。

CSRF

CSRF(cross-site request forgery),跨站請求偽造,通過偽造使用者資料請求發起攻擊。舉個例子:我在一個有 CSRF 缺陷的論壇回覆了一則熱門帖:贊!<img src="/api/cross-site?request=forgery" />,當使用者訪問到這條回覆時 img 標籤就會在使用者不知情的情況下以該使用者的身份發起提前設定的請求,比如:轉 1 積分到我自己的帳號上。

SQLi

SQLi(SQL injection),SQL 注入,通過在資料庫操作注入 SQL 片段發起攻擊。SQLi 是非常危險的攻擊,可以繞過系統中的各種限制直接對資料進行竊取和篡改。但同時, SQLi 又是比較容易防範的,只要對入參字串做好轉義處理就可以規避,常見的 ORM 模組都做好了此類處理。

DoS

DoS(denial-of-service),拒絕服務攻擊,通過大量的無效訪問讓應用陷入癱瘓。在 DoS 基礎上又有 DDoS(distributed denial-of-service),分散式拒絕服務攻擊,是加強版的 DoS。通常此類攻擊在傳輸層就已經做好了過濾,應用層一般在叢集入口也做了過濾,應用節點不需要再關心。

攻擊測試

再回到上一章已完成的工程 host1-tech/nodejs-server-examples - 07-authentication,當前的店鋪管理功恰好因為店鋪名稱長度校驗限制和沒有基於 http get 的變更介面而一定程度上規避了 XSS 和 CSRF 缺陷,另外因為資料庫訪問基於 ORM 實現也基本規避了 SQLi 缺陷。現在把長度校驗放鬆以進行 XSS 攻擊測試:

// src/moulds/ShopForm.js
const Yup = require('yup');

exports.createShopFormSchema = () =>
  Yup.object({
    name: Yup.string()
      .required('店鋪名不能為空')
      .min(3, '店鋪名至少 3 個字元')
      .max(120, '店鋪名不可超過 120 字'),
  });

XSS 攻擊 1 百草味<script>alert('XSS 攻擊 1 成功 ?')</script>

01d6e48c1c1817334982f49279dc7ec2b8445b03.gif

XSS 攻擊 2 廣州酒家<img src="_" onerror="alert('XSS 攻擊 2 成功 ?')"/>

0212343bd7e003b73a508cea89d3ea47a62fc56c.gif

基於 innerHTML 更新 DOM 時 script 標籤不會執行(詳見標準),所以 XSS 攻擊 1 無效。在換了新的寫法後,XSS 攻擊 2 就生效了。

強化網路安全

接下來通過 escape-htmlcsurfhelmet 對當前工程的網路安全進行強化,在工程根目錄執行以下安裝命令:

$ yarn add escape-html csurf helmet # 本地安裝 escape-html、csurf、helmet
# ...
info Direct dependencies
├─ csurf@1.11.0
├─ escape-html@1.0.3
└─ helmet@3.23.3
# ...

對店鋪資訊輸出做轉義處理:

// src/utils/escape-html-in-object.js
const escapeHtml = require('escape-html');

module.exports = function escapeHtmlInObject(input) {
  // 嘗試將 ORM 物件轉化為普通物件
  try {
    input = input.toJSON();
  } catch {}

  // 對型別為 string 的值轉義處理
  if (Array.isArray(input)) {
    return input.map(escapeHtmlInObject);
  } else if (typeof input == 'object') {
    const output = {};
    Object.keys(input).forEach(k => {
      output[k] = escapeHtmlInObject(input[k]);
    });
    return output;
  } else if (typeof input == 'string') {
    return escapeHtml(input);
  } else {
    return input;
  }
};
// src/controllers/shop.js
const { Router } = require('express');
const bodyParser = require('body-parser');
const shopService = require('../services/shop');
const { createShopFormSchema } = require('../moulds/ShopForm');
const cc = require('../utils/cc');
+const escapeHtmlInObject = require('../utils/escape-html-in-object');

class ShopController {
  shopService;

  async init() {
    this.shopService = await shopService();

    const router = Router();
    router.get('/', this.getAll);
    router.get('/:shopId', this.getOne);
    router.put('/:shopId', this.put);
    router.delete('/:shopId', this.delete);
    router.post('/', bodyParser.urlencoded({ extended: false }), this.post);
    return router;
  }

  getAll = cc(async (req, res) => {
    const { pageIndex, pageSize } = req.query;
    const shopList = await this.shopService.find({ pageIndex, pageSize });

-    res.send({ success: true, data: shopList });
+    res.send(escapeHtmlInObject({ success: true, data: shopList }));
  });

  getOne = cc(async (req, res) => {
    const { shopId } = req.params;
    const shopList = await this.shopService.find({ id: shopId });

    if (shopList.length) {
-      res.send({ success: true, data: shopList[0] });
+      res.send(escapeHtmlInObject({ success: true, data: shopList[0] }));
    } else {
      res.status(404).send({ success: false, data: null });
    }
  });

  put = cc(async (req, res) => {
    const { shopId } = req.params;
    const { name } = req.query;

    try {
      await createShopFormSchema().validate({ name });
    } catch (e) {
      res.status(400).send({ success: false, message: e.message });
      return;
    }

    const shopInfo = await this.shopService.modify({
      id: shopId,
      values: { name },
    });

    if (shopInfo) {
-      res.send({ success: true, data: shopInfo });
+      res.send(escapeHtmlInObject({ success: true, data: shopInfo }));
    } else {
      res.status(404).send({ success: false, data: null });
    }
  });

  delete = cc(async (req, res) => {
    const { shopId } = req.params;
    const success = await this.shopService.remove({ id: shopId });

    if (!success) {
      res.status(404);
    }
    res.send({ success });
  });

  post = cc(async (req, res) => {
    const { name } = req.body;

    try {
      await createShopFormSchema().validate({ name });
    } catch (e) {
      res.status(400).send({ success: false, message: e.message });
      return;
    }

    const shopInfo = await this.shopService.create({ values: { name } });

-    res.send({ success: true, data: shopInfo });
+    res.send(escapeHtmlInObject({ success: true, data: shopInfo }));
  });
}

module.exports = async () => {
  const c = new ShopController();
  return await c.init();
};

再次嘗試 XSS 攻擊 2 廣州酒家<img src="_" onerror="alert('XSS 攻擊 2 成功 ?')"/>

0dbee9993f5c51d1981d027f1eeb81c2a2fad7d2.gif

這樣就可以抵禦 XSS 攻擊了,現在再預防一下 CSRF 攻擊:

// src/middlewares/index.js
const { Router } = require('express');
const cookieParser = require('cookie-parser');
+const bodyParser = require('body-parser');
+const csurf = require('csurf');
const sessionMiddleware = require('./session');
const urlnormalizeMiddleware = require('./urlnormalize');
const loginMiddleware = require('./login');
const authMiddleware = require('./auth');

const secret = '842d918ced1888c65a650f993077c3d36b8f114d';

module.exports = async function initMiddlewares() {
  const router = Router();
  router.use(urlnormalizeMiddleware());
  router.use(cookieParser(secret));
  router.use(sessionMiddleware(secret));
  router.use(loginMiddleware());
  router.use(authMiddleware());
+  router.use(bodyParser.urlencoded({ extended: false }), csurf());
  return router;
};
// src/controllers/csrf.js
const { Router } = require('express');

class CsrfController {
  async init() {
    const router = Router();
    router.get('/script', this.getScript);
    return router;
  }

  getScript = (req, res) => {
    res.type('js');
    res.send(`window.__CSRF_TOKEN__='${req.csrfToken()}';`);
  };
}

module.exports = async () => {
  const c = new CsrfController();
  return await c.init();
};
const { parse } = require('url');

module.exports = function loginMiddleware(
  homepagePath = '/',
  loginPath = '/login.html',
  whiteList = {
    '/500.html': ['get'],
    '/api/health': ['get'],
+    '/api/csrf/script': ['get'],
    '/api/login': ['post'],
    '/api/login/github': ['get'],
    '/api/login/github/callback': ['get'],
  }
) {
  // ...
};
<!-- public/login.html -->
<html>
  <head>
    <meta charset="utf-8" />
+    <script src="/api/csrf/script"></script>
  </head>
  <body>
    <form method="post" action="/api/login">
+      <script>
+        document.write(
+          `<input type="hidden" name="_csrf" value="${__CSRF_TOKEN__}" />`
+        );
+      </script>
      <button type="submit">一鍵登入</button>
    </form>
    <a href="/api/login/github"><button>Github 登入</button></a>
  </body>
</html>
<!-- public/index.html -->
<html>
  <head>
    <meta charset="utf-8" />
    <link rel="stylesheet" href="./index.css" />
+    <script src="/api/csrf/script"></script>
  </head>
  <!-- ... -->
</html>
// public/index.js
// ...
export async function modifyShopInfo(e) {
  const shopId = e.target.parentElement.dataset.shopId;
  const name = e.target.parentElement.querySelector('input').value;

  try {
    await createShopFormSchema().validate({ name });
  } catch ({ message }) {
    e.target.parentElement.querySelector('.error').innerHTML = message;
    return;
  }

  await fetch(`/api/shop/${shopId}?name=${encodeURIComponent(name)}`, {
    method: 'PUT',
+    headers: {
+      'Csrf-Token': __CSRF_TOKEN__,
+    },
  });
  await refreshShopList();
}

export async function removeShopInfo(e) {
  const shopId = e.target.parentElement.dataset.shopId;
-  const res = await fetch(`/api/shop/${shopId}`, { method: 'DELETE' });
+  const res = await fetch(`/api/shop/${shopId}`, {
+    method: 'DELETE',
+    headers: {
+      'Csrf-Token': __CSRF_TOKEN__,
+    },
  });
  await refreshShopList();
}

export async function createShopInfo(e) {
  e.preventDefault();
  const name = e.target.parentElement.querySelector('input[name=name]').value;

  try {
    await createShopFormSchema().validate({ name });
  } catch ({ message }) {
    e.target.parentElement.querySelector('.error').innerHTML = message;
    return;
  }

  await fetch('/api/shop', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
+      'Csrf-Token': __CSRF_TOKEN__,
    },
    body: `name=${encodeURIComponent(name)}`,
  });

  await refreshShopList();
}

最後,使用 helmet 模組通過 http 頭控制瀏覽器提供更安全的環境:

const { Router } = require('express');
const cookieParser = require('cookie-parser');
const bodyParser = require('body-parser');
const csurf = require('csurf');
+const helmet = require('helmet');
const sessionMiddleware = require('./session');
const urlnormalizeMiddleware = require('./urlnormalize');
const loginMiddleware = require('./login');
const authMiddleware = require('./auth');

const secret = '842d918ced1888c65a650f993077c3d36b8f114d';

module.exports = async function initMiddlewares() {
  const router = Router();
+  router.use(helmet());
  router.use(urlnormalizeMiddleware());
  router.use(cookieParser(secret));
  router.use(sessionMiddleware(secret));
  router.use(loginMiddleware());
  router.use(authMiddleware());
  router.use(bodyParser.urlencoded({ extended: false }), csurf());
  return router;
};

以上是 Node.js 中常用的安全防範措施,有興趣的讀者可以在 OWASP 進一步瞭解。

本章原始碼

host1-tech/nodejs-server-examples - 08-security

更多閱讀

從零搭建 Node.js 企業級 Web 伺服器(零):靜態服務
從零搭建 Node.js 企業級 Web 伺服器(一):介面與分層
從零搭建 Node.js 企業級 Web 伺服器(二):校驗
從零搭建 Node.js 企業級 Web 伺服器(三):中介軟體
從零搭建 Node.js 企業級 Web 伺服器(四):異常處理
從零搭建 Node.js 企業級 Web 伺服器(五):資料庫訪問
從零搭建 Node.js 企業級 Web 伺服器(六):會話
從零搭建 Node.js 企業級 Web 伺服器(七):認證登入
從零搭建 Node.js 企業級 Web 伺服器(八):網路安全
從零搭建 Node.js 企業級 Web 伺服器(九):配置項
從零搭建 Node.js 企業級 Web 伺服器(十):日誌
從零搭建 Node.js 企業級 Web 伺服器(十一):定時任務

相關文章